diff options
| author | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-17 23:19:43 -0500 |
| commit | 32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch) | |
| tree | ee36d421d704e508d5617d803b4c8cbdb4804fe1 /Hutch/Views/Tickets/TicketDetailViewModel.swift | |
| parent | 8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff) | |
| download | hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2 hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip | |
v1.0
Diffstat (limited to 'Hutch/Views/Tickets/TicketDetailViewModel.swift')
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 551 |
1 files changed, 551 insertions, 0 deletions
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift new file mode 100644 index 0000000..70eaf0a --- /dev/null +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -0,0 +1,551 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct TicketDetailResponse: Decodable, Sendable { + let user: UserTrackerTicketWrapper +} + +private struct UserTrackerTicketWrapper: Decodable, Sendable { + let tracker: TrackerTicketWrapper +} + +private struct TrackerTicketWrapper: Decodable, Sendable { + let ticket: TicketDetailPayload +} + +private struct TicketDetailPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let title: String + let description: String? + let status: TicketStatus + let resolution: TicketResolution? + let authenticity: Authenticity + let submitter: Entity + let assignees: [Entity] + let labels: [TicketLabel] + let events: EventsPage +} + +private struct EventsPage: Decodable, Sendable { + let results: [TicketEvent] + let cursor: String? +} + +private struct SubmitCommentResponse: Decodable, Sendable { + let submitComment: SubmittedEvent +} + +private struct SubmittedEvent: Decodable, Sendable { + let id: Int + let created: Date + let changes: [EventChange] +} + +private struct MutationEventResponse: Decodable, Sendable { + let id: Int +} + +private struct UpdateStatusResponse: Decodable, Sendable { + let updateTicketStatus: UpdatedStatusEvent +} + +private struct UpdatedStatusEvent: Decodable, Sendable { + let eventType: String +} + +private struct AssignUserResponse: Decodable, Sendable { + let assignUser: MutationEventResponse +} + +private struct UnassignUserResponse: Decodable, Sendable { + let unassignUser: MutationEventResponse +} + +private struct LabelTicketResponse: Decodable, Sendable { + let labelTicket: MutationEventResponse +} + +private struct UnlabelTicketResponse: Decodable, Sendable { + let unlabelTicket: MutationEventResponse +} + +private struct UserLookupResponse: Decodable, Sendable { + let user: UserIdPayload +} + +private struct UserIdPayload: Decodable, Sendable { + let id: Int +} + +private struct CreateLabelResponse: Decodable, Sendable { + let createLabel: TicketLabel +} + +private struct TrackerLabelsResponse: Decodable, Sendable { + let user: UserTrackerLabelsWrapper +} + +private struct UserTrackerLabelsWrapper: Decodable, Sendable { + let tracker: TrackerLabelsWrapper +} + +private struct TrackerLabelsWrapper: Decodable, Sendable { + let labels: LabelsPage +} + +private struct LabelsPage: Decodable, Sendable { + let results: [TicketLabel] +} + +// MARK: - View Model + +@Observable +@MainActor +final class TicketDetailViewModel { + + let ownerUsername: String + let trackerName: String + let trackerId: Int + let trackerRid: String + let ticketId: Int + + private(set) var ticket: TicketDetail? + private(set) var events: [TicketEvent] = [] + private(set) var isLoading = false + private(set) var isSubmitting = false + private(set) var isPerformingAction = false + private(set) var trackerLabels: [TicketLabel] = [] + var commentText = "" + var error: String? + + private let client: SRHTClient + + private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool { + if lhs.created == rhs.created { + return lhs.id < rhs.id + } + return lhs.created < rhs.created + } + + init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { + self.ownerUsername = ownerUsername + self.trackerName = trackerName + self.trackerId = trackerId + self.trackerRid = trackerRid + self.ticketId = ticketId + self.client = client + } + + // MARK: - Queries + + private static let detailQuery = """ + query ticket($owner: String!, $tracker: String!, $ticketId: Int!) { + user(username: $owner) { + tracker(name: $tracker) { + ticket(id: $ticketId) { + id + created + updated + title: subject + description: body + status + resolution + authenticity + submitter { canonicalName } + assignees { canonicalName } + labels { id name backgroundColor foregroundColor } + events { + results { + id + created + changes { + eventType + ... on Comment { + author { canonicalName } + text + authenticity + } + ... on StatusChange { + oldStatus + newStatus + } + ... on LabelUpdate { + labeler { canonicalName } + label { name } + } + ... on Assignment { + assigner { canonicalName } + assignee { canonicalName } + } + ... on TicketMention { + mentioned { id } + } + ... on UserMention { + mentioned { canonicalName } + } + ... on Created { + author { canonicalName } + } + } + } + cursor + } + } + } + } + } + """ + + private static let submitCommentMutation = """ + mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) { + submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + id + created + changes { + eventType + ... on Comment { + author { canonicalName } + text + authenticity + } + } + } + } + """ + + private static let updateStatusMutation = """ + mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) { + updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) { + eventType: __typename + } + } + """ + + private static let assignUserMutation = """ + mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { + assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } + } + """ + + private static let unassignUserMutation = """ + mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { + unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } + } + """ + + private static let labelTicketMutation = """ + mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { + labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } + } + """ + + private static let unlabelTicketMutation = """ + mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { + unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } + } + """ + + private static let userLookupQuery = """ + query userLookup($username: String!) { + user(username: $username) { id } + } + """ + + private static let trackerLabelsQuery = """ + query trackerLabels($owner: String!, $tracker: String!) { + user(username: $owner) { + tracker(name: $tracker) { + labels { + results { id name backgroundColor foregroundColor } + } + } + } + } + """ + + private static let createLabelMutation = """ + mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) { + createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) { + id + name + backgroundColor + foregroundColor + } + } + """ + + // MARK: - Public API + + func loadTicket() async { + guard !isLoading else { return } + isLoading = true + error = nil + + do { + let result = try await client.execute( + service: .todo, + query: Self.detailQuery, + variables: [ + "owner": ownerUsername, + "tracker": trackerName, + "ticketId": ticketId + ], + responseType: TicketDetailResponse.self + ) + let payload = result.user.tracker.ticket + ticket = TicketDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + title: payload.title, + description: payload.description, + status: payload.status, + resolution: payload.resolution, + authenticity: payload.authenticity, + submitter: payload.submitter, + assignees: payload.assignees, + labels: payload.labels + ) + events = payload.events.results.sorted(by: Self.timelineOrder) + } catch { + self.error = error.localizedDescription + } + + isLoading = false + } + + func submitComment() async { + let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isSubmitting else { return } + isSubmitting = true + error = nil + + do { + let input: [String: any Sendable] = ["text": text] + let result = try await client.execute( + service: .todo, + query: Self.submitCommentMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "input": input + ], + responseType: SubmitCommentResponse.self + ) + // Append the returned event so the comment shows immediately. + let submitted = result.submitComment + let event = TicketEvent( + id: submitted.id, + created: submitted.created, + changes: submitted.changes + ) + events.append(event) + events.sort(by: Self.timelineOrder) + commentText = "" + } catch { + self.error = error.localizedDescription + } + + isSubmitting = false + } + + func updateComment(commentId: Int, text: String) async { + _ = commentId + _ = text + error = "Comment editing is not available in todo.sr.ht's public GraphQL API." + } + + // MARK: - Ticket Actions + + func updateStatus(status: TicketStatus, resolution: TicketResolution) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + let input: [String: any Sendable] = [ + "status": status.rawValue, + "resolution": resolution.rawValue + ] + _ = try await client.execute( + service: .todo, + query: Self.updateStatusMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "input": input + ], + responseType: UpdateStatusResponse.self + ) + // Re-fetch the ticket to get updated status/resolution + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func assignUser(username: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + // Resolve username to user ID + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": username], + responseType: UserLookupResponse.self + ) + let userId = userResult.user.id + + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": userId + ], + responseType: AssignUserResponse.self + ) + // Reload to reflect the change + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func unassignUser(username: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + // Resolve username to user ID + let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": stripped], + responseType: UserLookupResponse.self + ) + let userId = userResult.user.id + + _ = try await client.execute( + service: .todo, + query: Self.unassignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "userId": userId + ], + responseType: UnassignUserResponse.self + ) + // Reload to reflect the change + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func labelTicket(labelId: Int) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + _ = try await client.execute( + service: .todo, + query: Self.labelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "labelId": labelId + ], + responseType: LabelTicketResponse.self + ) + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func unlabelTicket(labelId: Int) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + _ = try await client.execute( + service: .todo, + query: Self.unlabelTicketMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketId, + "labelId": labelId + ], + responseType: UnlabelTicketResponse.self + ) + await loadTicket() + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + + func loadTrackerLabels() async { + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerLabelsQuery, + variables: [ + "owner": ownerUsername, + "tracker": trackerName + ], + responseType: TrackerLabelsResponse.self + ) + trackerLabels = result.user.tracker.labels.results + } catch { + self.error = error.localizedDescription + } + } + + func createLabel(name: String, backgroundColor: String, foregroundColor: String) async { + guard !isPerformingAction else { return } + isPerformingAction = true + error = nil + + do { + let result = try await client.execute( + service: .todo, + query: Self.createLabelMutation, + variables: [ + "trackerId": trackerId, + "name": name, + "backgroundColor": backgroundColor, + "foregroundColor": foregroundColor + ], + responseType: CreateLabelResponse.self + ) + trackerLabels.append(result.createLabel) + } catch { + self.error = error.localizedDescription + } + + isPerformingAction = false + } + +} |
