// // GithubClient+Issues.swift // Freetime // // Created by Ryan Nystrom on 6/2/17. // Copyright © 2017 Ryan Nystrom. All rights reserved. // import UIKit import IGListKit import GitHubAPI import Squawk private func uniqueAutocompleteUsers( left: [AutocompleteUser], right: [AutocompleteUser] ) -> [AutocompleteUser] { var uniqueUsers = Set() 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 let contentSizeCategory = UIContentSizeCategory.preferred client.query(query, result: { $0.repository }) { result in switch result { case .failure: completion(.error(nil)) Squawk.showGenericError() case .success(let repository): let issueOrPullRequest = repository.issueOrPullRequest guard let issueType: IssueType = issueOrPullRequest?.asIssue ?? issueOrPullRequest?.asPullRequest else { completion(.error(nil)) return } 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, contentSizeCategory: contentSizeCategory, 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, contentSizeCategory: contentSizeCategory, 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 targetBranchModel: IssueTargetBranchModel? if let baseBranchRef = issueType.targetBranch { targetBranchModel = IssueTargetBranchModel( branch: baseBranchRef, width: width ) } else { targetBranchModel = nil } let canAdmin = repository.viewerCanAdminister var availableMergeTypes = [IssueMergeType]() if repository.mergeCommitAllowed { availableMergeTypes.append(.merge) } if repository.squashMergeAllowed { availableMergeTypes.append(.squash) } if repository.rebaseMergeAllowed { availableMergeTypes.append(.rebase) } let issueResult = IssueResult( id: issueType.id, pullRequest: issueType.pullRequest, title: titleStringSizing(title: issueType.title, contentSizeCategory: contentSizeCategory, width: width), labels: IssueLabelsModel( status: IssueLabelStatusModel(status: status, pullRequest: issueType.pullRequest), locked: issueType.locked, labels: issueType.labelableFields.issueLabelModels ), assignee: createAssigneeModel(assigneeFields: issueType.assigneeFields), rootComment: rootComment, reviewers: issueType.reviewRequestModel, milestone: milestoneModel, targetBranch: targetBranchModel, timelinePages: [newPage] + (prependResult?.timelinePages ?? []), viewerCanUpdate: issueType.viewerCanUpdate, hasIssuesEnabled: repository.hasIssuesEnabled, viewerCanAdminister: canAdmin, defaultBranch: repository.defaultBranchRef?.name ?? "master", fileChanges: issueType.fileChanges, mergeModel: issueType.mergeModel(availableTypes: availableMergeTypes) ) DispatchQueue.main.async { // update the cache so all listeners receive the new model cache.set(value: issueResult) completion(.success((issueResult, mentionableUsers))) } } } } } func react( subjectID: String, content: ReactionContent, isAdd: Bool, completion: @escaping (IssueCommentReactionViewModel?) -> Void ) { let handler: (GitHubAPI.Result) -> Void = { result in switch result { case .success(let data): completion(createIssueReactions(reactions: data)) case .failure(let err): completion(nil) if let message = err?.localizedDescription { Squawk.showError(message: message) } else { Squawk.showGenericError() } } } if isAdd { client.mutate(AddReactionMutation(subject_id: subjectID, content: content), result: { data in data.addReaction?.subject.fragments.reactionFields }, completion: handler) } else { client.mutate(RemoveReactionMutation(subject_id: subjectID, content: content), result: { data in data.removeReaction?.subject.fragments.reactionFields }, completion: handler) } } func setStatus( previous: IssueResult, owner: String, repo: String, number: Int, close: Bool ) { let newLabels = IssueLabelsModel( status: IssueLabelStatusModel( status: close ? .closed : .open, pullRequest: previous.labels.status.pullRequest ), locked: previous.labels.locked, labels: previous.labels.labels ) 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( labels: newLabels, 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" client.send(V3SetIssueStatusRequest(owner: owner, repo: repo, number: number, state: stateString)) { result in switch result { case .success: break case .failure: cache.set(value: previous) Squawk.showGenericError() } } } func setLocked( previous: IssueResult, owner: String, repo: String, number: Int, locked: Bool, completion: ((Result) -> Void)? = nil ) { let newLabels = IssueLabelsModel( status: previous.labels.status, locked: locked, labels: previous.labels.labels ) 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( labels: newLabels, timelinePages: previous.timelinePages(appending: [newEvent]) ) let cache = self.cache // optimistically update the cache, listeners can react as appropriate cache.set(value: optimisticResult) client.send(V3LockIssueRequest(owner: owner, repo: repo, number: "\(number)", locked: locked)) { result in switch result { case .success: completion?(.success(true)) case .failure: cache.set(value: previous) Squawk.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) -> Void ) { guard let viewer = userSession?.username else { completion(.error(nil)) return } client.send(V3ViewerIsCollaboratorRequest(owner: owner, repo: repo, viewer: viewer)) { result in switch result { case .success(let response): completion(.success(response.data)) case .failure(let error): completion(.error(error)) } } } func mutateLabels( previous: IssueResult, owner: String, repo: String, number: Int, labels: [RepositoryLabel] ) { guard let actor = userSession?.username else { return } let contentSizeCategory = UIContentSizeCategory.preferred let oldLabelNames = Set(previous.labels.labels.map { $0.name }) let newLabelNames = Set(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, contentSizeCategory: contentSizeCategory, 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, contentSizeCategory: contentSizeCategory, width: 0 )) } } let optimistic = previous.updated( labels: IssueLabelsModel(status: previous.labels.status, locked: previous.labels.locked, labels: labels), timelinePages: previous.timelinePages(appending: newEvents) ) let cache = self.cache cache.set(value: optimistic) client.send(V3SetRepositoryLabelsRequest( owner: owner, repo: repo, number: number, labels: labels.map { $0.name }) ) { result in switch result { case .success: break case .failure: cache.set(value: previous) Squawk.showGenericError() } } } func addPeople( type: V3AddPeopleRequest.PeopleType, previous: IssueResult, owner: String, repo: String, number: Int, people: [IssueAssigneeViewModel] ) { guard let actor = userSession?.username else { return } let addedType: IssueRequestModel.Event let removedType: IssueRequestModel.Event let oldAssigness: Set switch type { case .assignees: addedType = .assigned removedType = .unassigned oldAssigness = Set(previous.assignee.users.map { $0.login }) case .reviewers: addedType = .reviewRequested removedType = .reviewRequestRemoved oldAssigness = Set(previous.reviewers?.users.map { $0.login } ?? []) } let newAssignees = Set(people.map { $0.login }) var newEvents = [IssueRequestModel]() var added = [String]() var removed = [String]() let contentSizeCategory = UIContentSizeCategory.preferred 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, contentSizeCategory: contentSizeCategory, 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, contentSizeCategory: contentSizeCategory, width: 0 // will be inflated when asked )) } } let timelinePages = previous.timelinePages(appending: newEvents) let optimistic: IssueResult switch type { case .assignees: optimistic = previous.updated( assignee: IssueAssigneesModel(users: people, type: .assigned), timelinePages: timelinePages ) case .reviewers: optimistic = previous.withReviewers( IssueAssigneesModel(users: people, type: .reviewRequested), timelinePages: timelinePages ) } let cache = self.cache cache.set(value: optimistic) // 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 { client.send(V3AddPeopleRequest( owner: owner, repo: repo, number: number, type: type, add: true, people: added) ) { result in switch result { case .success: break case .failure: cache.set(value: previous) Squawk.showGenericError() } } } // 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 { client.send(V3AddPeopleRequest( owner: owner, repo: repo, number: number, type: type, add: false, people: removed) ) { result in switch result { case .success: break case .failure: cache.set(value: previous) Squawk.showGenericError() } } } } }