Hutch/Views/Tickets/TicketDetailViewModel.swift

b5af561d68e7535726b79d4ceec0d0a45abf0042
hutch/Hutch/Views/Tickets/TicketDetailViewModel.swift history · blame · raw

551 lines · 17101 bytes

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TicketDetailResponse: Decodable, Sendable {
  6    let user: UserTrackerTicketWrapper
  7}
  8
  9private struct UserTrackerTicketWrapper: Decodable, Sendable {
 10    let tracker: TrackerTicketWrapper
 11}
 12
 13private struct TrackerTicketWrapper: Decodable, Sendable {
 14    let ticket: TicketDetailPayload
 15}
 16
 17private struct TicketDetailPayload: Decodable, Sendable {
 18    let id: Int
 19    let created: Date
 20    let updated: Date
 21    let title: String
 22    let description: String?
 23    let status: TicketStatus
 24    let resolution: TicketResolution?
 25    let authenticity: Authenticity
 26    let submitter: Entity
 27    let assignees: [Entity]
 28    let labels: [TicketLabel]
 29    let events: EventsPage
 30}
 31
 32private struct EventsPage: Decodable, Sendable {
 33    let results: [TicketEvent]
 34    let cursor: String?
 35}
 36
 37private struct SubmitCommentResponse: Decodable, Sendable {
 38    let submitComment: SubmittedEvent
 39}
 40
 41private struct SubmittedEvent: Decodable, Sendable {
 42    let id: Int
 43    let created: Date
 44    let changes: [EventChange]
 45}
 46
 47private struct MutationEventResponse: Decodable, Sendable {
 48    let id: Int
 49}
 50
 51private struct UpdateStatusResponse: Decodable, Sendable {
 52    let updateTicketStatus: UpdatedStatusEvent
 53}
 54
 55private struct UpdatedStatusEvent: Decodable, Sendable {
 56    let eventType: String
 57}
 58
 59private struct AssignUserResponse: Decodable, Sendable {
 60    let assignUser: MutationEventResponse
 61}
 62
 63private struct UnassignUserResponse: Decodable, Sendable {
 64    let unassignUser: MutationEventResponse
 65}
 66
 67private struct LabelTicketResponse: Decodable, Sendable {
 68    let labelTicket: MutationEventResponse
 69}
 70
 71private struct UnlabelTicketResponse: Decodable, Sendable {
 72    let unlabelTicket: MutationEventResponse
 73}
 74
 75private struct UserLookupResponse: Decodable, Sendable {
 76    let user: UserIdPayload
 77}
 78
 79private struct UserIdPayload: Decodable, Sendable {
 80    let id: Int
 81}
 82
 83private struct CreateLabelResponse: Decodable, Sendable {
 84    let createLabel: TicketLabel
 85}
 86
 87private struct TrackerLabelsResponse: Decodable, Sendable {
 88    let user: UserTrackerLabelsWrapper
 89}
 90
 91private struct UserTrackerLabelsWrapper: Decodable, Sendable {
 92    let tracker: TrackerLabelsWrapper
 93}
 94
 95private struct TrackerLabelsWrapper: Decodable, Sendable {
 96    let labels: LabelsPage
 97}
 98
 99private struct LabelsPage: Decodable, Sendable {
100    let results: [TicketLabel]
101}
102
103// MARK: - View Model
104
105@Observable
106@MainActor
107final class TicketDetailViewModel {
108
109    let ownerUsername: String
110    let trackerName: String
111    let trackerId: Int
112    let trackerRid: String
113    let ticketId: Int
114
115    private(set) var ticket: TicketDetail?
116    private(set) var events: [TicketEvent] = []
117    private(set) var isLoading = false
118    private(set) var isSubmitting = false
119    private(set) var isPerformingAction = false
120    private(set) var trackerLabels: [TicketLabel] = []
121    var commentText = ""
122    var error: String?
123
124    private let client: SRHTClient
125
126    private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
127        if lhs.created == rhs.created {
128            return lhs.id < rhs.id
129        }
130        return lhs.created < rhs.created
131    }
132
133    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
134        self.ownerUsername = ownerUsername
135        self.trackerName = trackerName
136        self.trackerId = trackerId
137        self.trackerRid = trackerRid
138        self.ticketId = ticketId
139        self.client = client
140    }
141
142    // MARK: - Queries
143
144    private static let detailQuery = """
145    query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
146        user(username: $owner) {
147            tracker(name: $tracker) {
148                ticket(id: $ticketId) {
149                    id
150                    created
151                    updated
152                    title: subject
153                    description: body
154                    status
155                    resolution
156                    authenticity
157                    submitter { canonicalName }
158                    assignees { canonicalName }
159                    labels { id name backgroundColor foregroundColor }
160                    events {
161                        results {
162                            id
163                            created
164                            changes {
165                                eventType
166                                ... on Comment {
167                                    author { canonicalName }
168                                    text
169                                    authenticity
170                                }
171                                ... on StatusChange {
172                                    oldStatus
173                                    newStatus
174                                }
175                                ... on LabelUpdate {
176                                    labeler { canonicalName }
177                                    label { name }
178                                }
179                                ... on Assignment {
180                                    assigner { canonicalName }
181                                    assignee { canonicalName }
182                                }
183                                ... on TicketMention {
184                                    mentioned { id }
185                                }
186                                ... on UserMention {
187                                    mentioned { canonicalName }
188                                }
189                                ... on Created {
190                                    author { canonicalName }
191                                }
192                            }
193                        }
194                        cursor
195                    }
196                }
197            }
198        }
199    }
200    """
201
202    private static let submitCommentMutation = """
203    mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
204        submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
205            id
206            created
207            changes {
208                eventType
209                ... on Comment {
210                    author { canonicalName }
211                    text
212                    authenticity
213                }
214            }
215        }
216    }
217    """
218
219    private static let updateStatusMutation = """
220    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
221        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
222            eventType: __typename
223        }
224    }
225    """
226
227    private static let assignUserMutation = """
228    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
229        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
230    }
231    """
232
233    private static let unassignUserMutation = """
234    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
235        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
236    }
237    """
238
239    private static let labelTicketMutation = """
240    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
241        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
242    }
243    """
244
245    private static let unlabelTicketMutation = """
246    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
247        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
248    }
249    """
250
251    private static let userLookupQuery = """
252    query userLookup($username: String!) {
253        user(username: $username) { id }
254    }
255    """
256
257    private static let trackerLabelsQuery = """
258    query trackerLabels($owner: String!, $tracker: String!) {
259        user(username: $owner) {
260            tracker(name: $tracker) {
261                labels {
262                    results { id name backgroundColor foregroundColor }
263                }
264            }
265        }
266    }
267    """
268
269    private static let createLabelMutation = """
270    mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
271        createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
272            id
273            name
274            backgroundColor
275            foregroundColor
276        }
277    }
278    """
279
280    // MARK: - Public API
281
282    func loadTicket() async {
283        guard !isLoading else { return }
284        isLoading = true
285        error = nil
286
287        do {
288            let result = try await client.execute(
289                service: .todo,
290                query: Self.detailQuery,
291                variables: [
292                    "owner": ownerUsername,
293                    "tracker": trackerName,
294                    "ticketId": ticketId
295                ],
296                responseType: TicketDetailResponse.self
297            )
298            let payload = result.user.tracker.ticket
299            ticket = TicketDetail(
300                id: payload.id,
301                created: payload.created,
302                updated: payload.updated,
303                title: payload.title,
304                description: payload.description,
305                status: payload.status,
306                resolution: payload.resolution,
307                authenticity: payload.authenticity,
308                submitter: payload.submitter,
309                assignees: payload.assignees,
310                labels: payload.labels
311            )
312            events = payload.events.results.sorted(by: Self.timelineOrder)
313        } catch {
314            self.error = error.localizedDescription
315        }
316
317        isLoading = false
318    }
319
320    func submitComment() async {
321        let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
322        guard !text.isEmpty, !isSubmitting else { return }
323        isSubmitting = true
324        error = nil
325
326        do {
327            let input: [String: any Sendable] = ["text": text]
328            let result = try await client.execute(
329                service: .todo,
330                query: Self.submitCommentMutation,
331                variables: [
332                    "trackerId": trackerId,
333                    "ticketId": ticketId,
334                    "input": input
335                ],
336                responseType: SubmitCommentResponse.self
337            )
338            // Append the returned event so the comment shows immediately.
339            let submitted = result.submitComment
340            let event = TicketEvent(
341                id: submitted.id,
342                created: submitted.created,
343                changes: submitted.changes
344            )
345            events.append(event)
346            events.sort(by: Self.timelineOrder)
347            commentText = ""
348        } catch {
349            self.error = error.localizedDescription
350        }
351
352        isSubmitting = false
353    }
354    
355    func updateComment(commentId: Int, text: String) async {
356        _ = commentId
357        _ = text
358        error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
359    }
360
361    // MARK: - Ticket Actions
362
363    func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
364        guard !isPerformingAction else { return }
365        isPerformingAction = true
366        error = nil
367
368        do {
369            let input: [String: any Sendable] = [
370                "status": status.rawValue,
371                "resolution": resolution.rawValue
372            ]
373            _ = try await client.execute(
374                service: .todo,
375                query: Self.updateStatusMutation,
376                variables: [
377                    "trackerId": trackerId,
378                    "ticketId": ticketId,
379                    "input": input
380                ],
381                responseType: UpdateStatusResponse.self
382            )
383            // Re-fetch the ticket to get updated status/resolution
384            await loadTicket()
385        } catch {
386            self.error = error.localizedDescription
387        }
388
389        isPerformingAction = false
390    }
391
392    func assignUser(username: String) async {
393        guard !isPerformingAction else { return }
394        isPerformingAction = true
395        error = nil
396
397        do {
398            // Resolve username to user ID
399            let userResult = try await client.execute(
400                service: .todo,
401                query: Self.userLookupQuery,
402                variables: ["username": username],
403                responseType: UserLookupResponse.self
404            )
405            let userId = userResult.user.id
406
407            _ = try await client.execute(
408                service: .todo,
409                query: Self.assignUserMutation,
410                variables: [
411                    "trackerId": trackerId,
412                    "ticketId": ticketId,
413                    "userId": userId
414                ],
415                responseType: AssignUserResponse.self
416            )
417            // Reload to reflect the change
418            await loadTicket()
419        } catch {
420            self.error = error.localizedDescription
421        }
422
423        isPerformingAction = false
424    }
425
426    func unassignUser(username: String) async {
427        guard !isPerformingAction else { return }
428        isPerformingAction = true
429        error = nil
430
431        do {
432            // Resolve username to user ID
433            let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
434            let userResult = try await client.execute(
435                service: .todo,
436                query: Self.userLookupQuery,
437                variables: ["username": stripped],
438                responseType: UserLookupResponse.self
439            )
440            let userId = userResult.user.id
441
442            _ = try await client.execute(
443                service: .todo,
444                query: Self.unassignUserMutation,
445                variables: [
446                    "trackerId": trackerId,
447                    "ticketId": ticketId,
448                    "userId": userId
449                ],
450                responseType: UnassignUserResponse.self
451            )
452            // Reload to reflect the change
453            await loadTicket()
454        } catch {
455            self.error = error.localizedDescription
456        }
457
458        isPerformingAction = false
459    }
460
461    func labelTicket(labelId: Int) async {
462        guard !isPerformingAction else { return }
463        isPerformingAction = true
464        error = nil
465
466        do {
467            _ = try await client.execute(
468                service: .todo,
469                query: Self.labelTicketMutation,
470                variables: [
471                    "trackerId": trackerId,
472                    "ticketId": ticketId,
473                    "labelId": labelId
474                ],
475                responseType: LabelTicketResponse.self
476            )
477            await loadTicket()
478        } catch {
479            self.error = error.localizedDescription
480        }
481
482        isPerformingAction = false
483    }
484
485    func unlabelTicket(labelId: Int) async {
486        guard !isPerformingAction else { return }
487        isPerformingAction = true
488        error = nil
489
490        do {
491            _ = try await client.execute(
492                service: .todo,
493                query: Self.unlabelTicketMutation,
494                variables: [
495                    "trackerId": trackerId,
496                    "ticketId": ticketId,
497                    "labelId": labelId
498                ],
499                responseType: UnlabelTicketResponse.self
500            )
501            await loadTicket()
502        } catch {
503            self.error = error.localizedDescription
504        }
505
506        isPerformingAction = false
507    }
508
509    func loadTrackerLabels() async {
510        do {
511            let result = try await client.execute(
512                service: .todo,
513                query: Self.trackerLabelsQuery,
514                variables: [
515                    "owner": ownerUsername,
516                    "tracker": trackerName
517                ],
518                responseType: TrackerLabelsResponse.self
519            )
520            trackerLabels = result.user.tracker.labels.results
521        } catch {
522            self.error = error.localizedDescription
523        }
524    }
525
526    func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
527        guard !isPerformingAction else { return }
528        isPerformingAction = true
529        error = nil
530
531        do {
532            let result = try await client.execute(
533                service: .todo,
534                query: Self.createLabelMutation,
535                variables: [
536                    "trackerId": trackerId,
537                    "name": name,
538                    "backgroundColor": backgroundColor,
539                    "foregroundColor": foregroundColor
540                ],
541                responseType: CreateLabelResponse.self
542            )
543            trackerLabels.append(result.createLabel)
544        } catch {
545            self.error = error.localizedDescription
546        }
547
548        isPerformingAction = false
549    }
550
551}