Files
GitHawk/Classes/Issues/GithubClient+Issues.swift
Ryan Nystrom eb7069f6f7 Refactor date formatting from String (#1229)
* refactor date formatting from String

* undo new file
2017-12-16 22:16:01 -05:00

514 lines
18 KiB
Swift

//
// GithubClient+Issues.swift
// Freetime
//
// Created by Ryan Nystrom on 6/2/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import UIKit
import IGListKit
private func uniqueAutocompleteUsers(
left: [AutocompleteUser],
right: [AutocompleteUser]
) -> [AutocompleteUser] {
var uniqueUsers = Set<String>()
var mentionableUsers = [AutocompleteUser]()
for user in left {
if !uniqueUsers.contains(user.login) {
uniqueUsers.insert(user.login)
mentionableUsers.append(user)
}
}
for user in right {
if !uniqueUsers.contains(user.login) {
uniqueUsers.insert(user.login)
mentionableUsers.append(user)
}
}
return mentionableUsers
}
extension GithubClient {
func fetch(
owner: String,
repo: String,
number: Int,
width: CGFloat,
prependResult: IssueResult?,
completion: @escaping (Result<(IssueResult, [AutocompleteUser])>) -> Void
) {
let query = IssueOrPullRequestQuery(
owner: owner,
repo: repo,
number: number,
page_size: 30,
before: prependResult?.minStartCursor
)
let cache = self.cache
fetch(query: query) { (result, error) in
let repository = result?.data?.repository
let issueOrPullRequest = repository?.issueOrPullRequest
if let issueType: IssueType = issueOrPullRequest?.asIssue ?? issueOrPullRequest?.asPullRequest {
DispatchQueue.global().async {
let status: IssueStatus = issueType.merged ? .merged : issueType.closableFields.closed ? .closed : .open
let rootComment = createCommentModel(
id: issueType.id,
commentFields: issueType.commentFields,
reactionFields: issueType.reactionFields,
width: width,
owner: owner,
repo: repo,
threadState: .single,
viewerCanUpdate: issueType.viewerCanUpdate,
viewerCanDelete: false, // Root comment can not be deleted
isRoot: true
)
let timeline = issueType.timelineViewModels(owner: owner, repo: repo, width: width)
// append the issue author for autocomplete
var mentionedUsers = timeline.mentionedUsers
if let details = rootComment?.details {
mentionedUsers.append(AutocompleteUser(
avatarURL: details.avatarURL,
login: details.login
))
}
let mentionableUsers = uniqueAutocompleteUsers(
left: mentionedUsers,
right: repository?.mentionableUsers.autocompleteUsers ?? []
)
let paging = issueType.headPaging
let newPage = IssueTimelinePage(
startCursor: paging.hasPreviousPage ? paging.startCursor : nil,
viewModels: timeline.models
)
let milestoneModel: Milestone?
if let milestone = issueType.milestoneFields {
milestoneModel = Milestone(
number: milestone.number,
title: milestone.title,
dueOn: milestone.dueOn?.githubDate,
openIssueCount: milestone.openCount.totalCount,
totalIssueCount: milestone.totalCount.totalCount
)
} else {
milestoneModel = nil
}
let canAdmin = repository?.viewerCanAdminister ?? false
let issueResult = IssueResult(
id: issueType.id,
pullRequest: issueType.pullRequest,
status: IssueStatusModel(status: status, pullRequest: issueType.pullRequest, locked: issueType.locked),
title: titleStringSizing(title: issueType.title, width: width),
labels: IssueLabelsModel(labels: issueType.labelableFields.issueLabelModels),
assignee: createAssigneeModel(assigneeFields: issueType.assigneeFields),
rootComment: rootComment,
reviewers: issueType.reviewRequestModel,
milestone: milestoneModel,
timelinePages: [newPage] + (prependResult?.timelinePages ?? []),
viewerCanUpdate: issueType.viewerCanUpdate,
hasIssuesEnabled: repository?.hasIssuesEnabled ?? false,
viewerCanAdminister: canAdmin,
defaultBranch: repository?.defaultBranchRef?.name ?? "master",
fileChanges: issueType.fileChanges
)
DispatchQueue.main.async {
// update the cache so all listeners receive the new model
cache.set(value: issueResult)
completion(.success((issueResult, mentionableUsers)))
}
}
} else {
completion(.error(nil))
}
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
}
}
func react(
subjectID: String,
content: ReactionContent,
isAdd: Bool,
completion: @escaping (IssueCommentReactionViewModel?) -> Void
) {
if isAdd {
perform(mutation: AddReactionMutation(subject_id: subjectID, content: content)) { (result, error) in
if let reactionFields = result?.data?.addReaction?.subject.fragments.reactionFields {
completion(createIssueReactions(reactions: reactionFields))
} else {
completion(nil)
}
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
}
} else {
perform(mutation: RemoveReactionMutation(subject_id: subjectID, content: content)) { (result, error) in
if let reactionFields = result?.data?.removeReaction?.subject.fragments.reactionFields {
completion(createIssueReactions(reactions: reactionFields))
} else {
completion(nil)
}
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
}
}
}
func setStatus(
previous: IssueResult,
owner: String,
repo: String,
number: Int,
close: Bool
) {
let newStatus = IssueStatusModel(
status: close ? .closed : .open,
pullRequest: previous.status.pullRequest,
locked: previous.status.locked
)
let newEvent = IssueStatusEventModel(
id: UUID().uuidString,
actor: userSession?.username ?? Constants.Strings.unknown,
commitHash: nil,
date: Date(),
status: close ? .closed : .reopened,
pullRequest: previous.pullRequest
)
let optimisticResult = previous.updated(
status: newStatus,
timelinePages: previous.timelinePages(appending: [newEvent])
)
let cache = self.cache
// optimistically update the cache, listeners can react as appropriate
cache.set(value: optimisticResult)
let stateString = close ? "closed" : "open"
// https://developer.github.com/v3/issues/#edit-an-issue
request(Request(
path: "repos/\(owner)/\(repo)/issues/\(number)",
method: .patch,
parameters: [ "state": stateString ],
completion: { (response, _) in
// rewind to a previous object if response isn't a success
if response.response?.statusCode != 200 {
cache.set(value: previous)
ToastManager.showGenericError()
}
}))
}
func deleteComment(owner: String, repo: String, commentID: Int, completion: @escaping (Result<Bool>) -> Void) {
request(Request(path: "repos/\(owner)/\(repo)/issues/comments/\(commentID)", method: .delete, completion: { (response, _) in
// As per documentation this endpoint returns no content, so all we can validate is that
// the status code is "204 No Content".
if response.response?.statusCode == 204 {
completion(.success(true))
} else {
completion(.error(response.error))
}
}))
}
func setLocked(
previous: IssueResult,
owner: String,
repo: String,
number: Int,
locked: Bool,
completion: ((Result<Bool>) -> Void)? = nil
) {
let newStatus = IssueStatusModel(
status: previous.status.status,
pullRequest: previous.status.pullRequest,
locked: locked
)
let newEvent = IssueStatusEventModel(
id: UUID().uuidString,
actor: userSession?.username ?? Constants.Strings.unknown,
commitHash: nil,
date: Date(),
status: locked ? .locked : .unlocked,
pullRequest: previous.pullRequest
)
let optimisticResult = previous.updated(
status: newStatus,
timelinePages: previous.timelinePages(appending: [newEvent])
)
let cache = self.cache
// optimistically update the cache, listeners can react as appropriate
cache.set(value: optimisticResult)
request(Request(
path: "repos/\(owner)/\(repo)/issues/\(number)/lock",
method: locked ? .put : .delete,
completion: { (response, _) in
// As per documentation this endpoint returns no content, so all we can validate is that
// the status code is "204 No Content".
if response.response?.statusCode == 204 {
completion?(.success(true))
} else {
cache.set(value: previous)
ToastManager.showGenericError()
completion?(.error(nil))
}
}))
}
enum CollaboratorPermission: String {
case admin
case write
case read
case none
static func from(_ str: String) -> CollaboratorPermission {
return CollaboratorPermission(rawValue: str) ?? .none
}
var canManage: Bool {
switch self {
case .admin, .write:
return true
case .read, .none:
return false
}
}
}
func fetchViewerCollaborator(
owner: String,
repo: String,
completion: @escaping (Result<CollaboratorPermission>) -> Void
) {
guard let viewer = userSession?.username else {
completion(.error(nil))
return
}
// https://developer.github.com/v3/repos/collaborators/#review-a-users-permission-level
request(Request(
path: "repos/\(owner)/\(repo)/collaborators/\(viewer)/permission",
headers: ["Accept": "application/vnd.github.hellcat-preview+json"],
completion: { (response, _) in
let statusCode = response.response?.statusCode
if statusCode == 200,
let json = response.value as? [String: Any],
let permission = json["permission"] as? String {
completion(.success(CollaboratorPermission.from(permission)))
} else if statusCode == 403 {
completion(.success(.none))
} else {
completion(.error(response.error))
}
}))
}
func mutateLabels(
previous: IssueResult,
owner: String,
repo: String,
number: Int,
labels: [RepositoryLabel]
) {
guard let actor = userSession?.username else { return }
let oldLabelNames = Set<String>(previous.labels.labels.map { $0.name })
let newLabelNames = Set<String>(labels.map { $0.name })
var newEvents = [IssueLabeledModel]()
for newLabel in labels {
if !oldLabelNames.contains(newLabel.name) {
newEvents.append(IssueLabeledModel(
id: UUID().uuidString,
actor: actor,
title: newLabel.name,
color: newLabel.color,
date: Date(),
type: .added,
repoOwner: owner,
repoName: repo,
width: 0
))
}
}
for oldLabel in previous.labels.labels {
if !newLabelNames.contains(oldLabel.name) {
newEvents.append(IssueLabeledModel(
id: UUID().uuidString,
actor: actor,
title: oldLabel.name,
color: oldLabel.color,
date: Date(),
type: .removed,
repoOwner: owner,
repoName: repo,
width: 0
))
}
}
let optimistic = previous.updated(
labels: IssueLabelsModel(labels: labels),
timelinePages: previous.timelinePages(appending: newEvents)
)
let cache = self.cache
cache.set(value: optimistic)
request(GithubClient.Request(
path: "repos/\(owner)/\(repo)/issues/\(number)",
method: .patch,
parameters: ["labels": labels.map { $0.name }]
) { (response, _) in
if let statusCode = response.response?.statusCode, statusCode != 200 {
cache.set(value: previous)
if statusCode == 403 {
ToastManager.showPermissionsError()
} else {
ToastManager.showGenericError()
}
}
})
}
enum AddPeopleType {
case assignee
case reviewer
}
func addPeople(
type: AddPeopleType,
previous: IssueResult,
owner: String,
repo: String,
number: Int,
people: [IssueAssigneeViewModel]
) {
guard let actor = userSession?.username else { return }
let path: String
let param: String
let addedType: IssueRequestModel.Event
let removedType: IssueRequestModel.Event
let oldAssigness: Set<String>
switch type {
case .assignee:
path = "repos/\(owner)/\(repo)/issues/\(number)/assignees"
param = "assignees"
addedType = .assigned
removedType = .unassigned
oldAssigness = Set<String>(previous.assignee.users.map { $0.login })
case .reviewer:
path = "repos/\(owner)/\(repo)/pulls/\(number)/requested_reviewers"
param = "reviewers"
addedType = .reviewRequested
removedType = .reviewRequestRemoved
oldAssigness = Set<String>(previous.reviewers?.users.map { $0.login } ?? [])
}
let newAssignees = Set<String>(people.map { $0.login })
var newEvents = [IssueRequestModel]()
var added = [String]()
var removed = [String]()
for old in oldAssigness {
if !newAssignees.contains(old) {
removed.append(old)
newEvents.append(IssueRequestModel(
id: UUID().uuidString,
actor: actor,
user: old,
date: Date(),
event: removedType,
width: 0 // will be inflated when asked
))
}
}
for new in newAssignees {
if !oldAssigness.contains(new) {
added.append(new)
newEvents.append(IssueRequestModel(
id: UUID().uuidString,
actor: actor,
user: new,
date: Date(),
event: addedType,
width: 0 // will be inflated when asked
))
}
}
let timelinePages = previous.timelinePages(appending: newEvents)
let optimistic: IssueResult
switch type {
case .assignee:
optimistic = previous.updated(
assignee: IssueAssigneesModel(users: people, type: .assigned),
timelinePages: timelinePages
)
case .reviewer:
optimistic = previous.withReviewers(
IssueAssigneesModel(users: people, type: .reviewRequested),
timelinePages: timelinePages
)
}
let cache = self.cache
cache.set(value: optimistic)
let handler: (Int, Int?) -> Void = { (expect, status) in
if status != expect {
cache.set(value: previous)
ToastManager.showGenericError()
}
}
// https://developer.github.com/v3/issues/assignees/#add-assignees-to-an-issue
// https://developer.github.com/v3/pulls/review_requests/#create-a-review-request
if added.count > 0 {
request(GithubClient.Request(
path: path,
method: .post,
parameters: [param: added]
) { (response, _) in
handler(201, response.response?.statusCode)
})
}
// https://developer.github.com/v3/issues/assignees/#remove-assignees-from-an-issue
// https://developer.github.com/v3/pulls/review_requests/#delete-a-review-request
if removed.count > 0 {
request(GithubClient.Request(
path: path,
method: .delete,
parameters: [param: removed]
) { (response, _) in
handler(200, response.response?.statusCode)
})
}
}
}