krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.3.1: Hutch/Views/Tickets/TicketDetailViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
  4
  5private struct TicketDetailResponse: Decodable, Sendable {
  6    let tracker: TrackerTicketWrapper
  7}
  8
  9private struct TrackerTicketWrapper: Decodable, Sendable {
 10    let ticket: TicketDetailPayload
 11}
 12
 13private struct TicketDetailPayload: Decodable, Sendable {
 14    let id: Int
 15    let created: Date
 16    let updated: Date
 17    let title: String
 18    let description: String?
 19    let status: TicketStatus
 20    let resolution: TicketResolution?
 21    let authenticity: Authenticity
 22    let submitter: Entity
 23    let assignees: [Entity]
 24    let labels: [TicketLabel]
 25    let events: EventsPage
 26}
 27
 28private struct EventsPage: Decodable, Sendable {
 29    let results: [TicketEvent]
 30    let cursor: String?
 31}
 32
 33private struct SubmitCommentResponse: Decodable, Sendable {
 34    let submitComment: SubmittedEvent
 35}
 36
 37private struct SubmittedEvent: Decodable, Sendable {
 38    let id: Int
 39    let created: Date
 40    let changes: [EventChange]
 41}
 42
 43private struct MutationEventResponse: Decodable, Sendable {
 44    let id: Int
 45}
 46
 47private struct UpdateStatusResponse: Decodable, Sendable {
 48    let updateTicketStatus: UpdatedStatusEvent
 49}
 50
 51private struct UpdatedStatusEvent: Decodable, Sendable {
 52    let eventType: String
 53}
 54
 55private struct AssignUserResponse: Decodable, Sendable {
 56    let assignUser: MutationEventResponse
 57}
 58
 59private struct UnassignUserResponse: Decodable, Sendable {
 60    let unassignUser: MutationEventResponse
 61}
 62
 63private struct LabelTicketResponse: Decodable, Sendable {
 64    let labelTicket: MutationEventResponse
 65}
 66
 67private struct UnlabelTicketResponse: Decodable, Sendable {
 68    let unlabelTicket: MutationEventResponse
 69}
 70
 71private struct UserLookupResponse: Decodable, Sendable {
 72    let user: UserIdPayload
 73}
 74
 75private struct UserIdPayload: Decodable, Sendable {
 76    let id: Int
 77}
 78
 79private struct CreateLabelResponse: Decodable, Sendable {
 80    let createLabel: TicketLabel
 81}
 82
 83private struct TrackerLabelsResponse: Decodable, Sendable {
 84    let tracker: TrackerLabelsWrapper
 85}
 86
 87private struct TrackerLabelsWrapper: Decodable, Sendable {
 88    let labels: LabelsPage
 89}
 90
 91private struct LabelsPage: Decodable, Sendable {
 92    let results: [TicketLabel]
 93}
 94
 95// MARK: - View Model
 96
 97@Observable
 98@MainActor
 99final class TicketDetailViewModel {
100    private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
101        APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
102    }
103
104    let ownerUsername: String
105    let trackerName: String
106    let trackerId: Int
107    let trackerRid: String
108    let ticketId: Int
109
110    private(set) var ticket: TicketDetail?
111    private(set) var events: [TicketEvent] = []
112    private(set) var isLoading = false
113    private(set) var isSubmitting = false
114    private(set) var isPerformingAction = false
115    private(set) var trackerLabels: [TicketLabel] = []
116    private(set) var rawTicketResponse: String?
117    private(set) var cacheMetadata: CacheEntryMetadata?
118    private(set) var isRefreshingCachedData = false
119    var commentText = ""
120    var error: String?
121
122    private let client: SRHTClient
123
124    private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
125        if lhs.created == rhs.created {
126            return lhs.id < rhs.id
127        }
128        return lhs.created < rhs.created
129    }
130
131    static func statusUpdateInput(
132        status: TicketStatus,
133        resolution: TicketResolution?
134    ) -> [String: any Sendable] {
135        var input: [String: any Sendable] = [
136            "status": status.rawValue
137        ]
138        if status == .resolved, let resolution {
139            input["resolution"] = resolution.rawValue
140        }
141        return input
142    }
143
144    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
145        self.ownerUsername = ownerUsername
146        self.trackerName = trackerName
147        self.trackerId = trackerId
148        self.trackerRid = trackerRid
149        self.ticketId = ticketId
150        self.client = client
151    }
152
153    // MARK: - Queries
154
155    private static let detailQuery = """
156    query ticket($rid: ID!, $ticketId: Int!) {
157        tracker(rid: $rid) {
158            ticket(id: $ticketId) {
159                id
160                created
161                updated
162                title: subject
163                description: body
164                status
165                resolution
166                authenticity
167                submitter { canonicalName }
168                assignees { canonicalName }
169                labels { id name backgroundColor foregroundColor }
170                events {
171                    results {
172                        id
173                        created
174                        changes {
175                            eventType
176                            ... on Comment {
177                                author { canonicalName }
178                                text
179                                authenticity
180                            }
181                            ... on StatusChange {
182                                oldStatus
183                                newStatus
184                            }
185                            ... on LabelUpdate {
186                                labeler { canonicalName }
187                                label { name }
188                            }
189                            ... on Assignment {
190                                assigner { canonicalName }
191                                assignee { canonicalName }
192                            }
193                            ... on TicketMention {
194                                mentioned { id }
195                            }
196                            ... on UserMention {
197                                mentioned { canonicalName }
198                            }
199                            ... on Created {
200                                author { canonicalName }
201                            }
202                        }
203                    }
204                    cursor
205                }
206            }
207        }
208    }
209    """
210
211    private static let submitCommentMutation = """
212    mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
213        submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
214            id
215            created
216            changes {
217                eventType
218                ... on Comment {
219                    author { canonicalName }
220                    text
221                    authenticity
222                }
223            }
224        }
225    }
226    """
227
228    private static let updateStatusMutation = """
229    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
230        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
231            eventType: __typename
232        }
233    }
234    """
235
236    private static let assignUserMutation = """
237    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
238        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
239    }
240    """
241
242    private static let unassignUserMutation = """
243    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
244        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
245    }
246    """
247
248    private static let labelTicketMutation = """
249    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
250        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
251    }
252    """
253
254    private static let unlabelTicketMutation = """
255    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
256        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
257    }
258    """
259
260    private static let userLookupQuery = """
261    query userLookup($username: String!) {
262        user(username: $username) { id }
263    }
264    """
265
266    private static let trackerLabelsQuery = """
267    query trackerLabels($rid: ID!) {
268        tracker(rid: $rid) {
269            labels {
270                results { id name backgroundColor foregroundColor }
271            }
272        }
273    }
274    """
275
276    private static let createLabelMutation = """
277    mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
278        createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
279            id
280            name
281            backgroundColor
282            foregroundColor
283        }
284    }
285    """
286
287    // MARK: - Public API
288
289    func loadTicket() async {
290        guard !isLoading else { return }
291        isLoading = true
292        error = nil
293        rawTicketResponse = nil
294
295        do {
296            let result = try await client.executeCached(
297                service: .todo,
298                query: Self.detailQuery,
299                variables: [
300                    "rid": trackerRid,
301                    "ticketId": ticketId
302                ],
303                responseType: TicketDetailResponse.self,
304                cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
305                resourceType: .ticketDetail,
306                ttl: APICacheTTLs.ticketDetail,
307                policy: .cacheFirstThenRefresh
308            )
309            apply(result.value, metadata: result.metadata)
310            if result.isFromCache {
311                isLoading = false
312                await refreshTicketInBackground()
313                return
314            }
315        } catch {
316            self.error = error.userFacingMessage
317        }
318
319        isLoading = false
320    }
321
322    func loadTicketWithDebugCapture() async {
323        guard !isLoading else { return }
324        isLoading = true
325        error = nil
326
327        do {
328            let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
329            let result = try await client.executeCached(
330                service: .todo,
331                query: Self.detailQuery,
332                variables: [
333                    "rid": trackerRid,
334                    "ticketId": ticketId
335                ],
336                responseType: TicketDetailResponse.self,
337                cacheKey: cacheKey,
338                resourceType: .ticketDetail,
339                ttl: APICacheTTLs.ticketDetail,
340                policy: .refreshIgnoringCache
341            )
342            rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
343                .flatMap { String(data: $0, encoding: .utf8) }
344            apply(result.value, metadata: result.metadata)
345        } catch {
346            self.error = error.userFacingMessage
347        }
348
349        isLoading = false
350    }
351
352    func submitComment() async {
353        let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
354        guard !text.isEmpty, !isSubmitting else { return }
355        isSubmitting = true
356        error = nil
357
358        do {
359            let input: [String: any Sendable] = ["text": text]
360            let result = try await client.execute(
361                service: .todo,
362                query: Self.submitCommentMutation,
363                variables: [
364                    "trackerId": trackerId,
365                    "ticketId": ticketId,
366                    "input": input
367                ],
368                responseType: SubmitCommentResponse.self
369            )
370            // Append the returned event so the comment shows immediately.
371            let submitted = result.submitComment
372            let event = TicketEvent(
373                id: submitted.id,
374                created: submitted.created,
375                changes: submitted.changes
376            )
377            events.append(event)
378            events.sort(by: Self.timelineOrder)
379            commentText = ""
380            await invalidateAfterMutation()
381        } catch {
382            self.error = error.userFacingMessage
383        }
384
385        isSubmitting = false
386    }
387    
388    func updateComment(commentId: Int, text: String) async {
389        _ = commentId
390        _ = text
391        error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
392    }
393
394    // MARK: - Ticket Actions
395
396    func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
397        guard !isPerformingAction else { return }
398        isPerformingAction = true
399        error = nil
400
401        do {
402            let input = Self.statusUpdateInput(status: status, resolution: resolution)
403            _ = try await client.execute(
404                service: .todo,
405                query: Self.updateStatusMutation,
406                variables: [
407                    "trackerId": trackerId,
408                    "ticketId": ticketId,
409                    "input": input
410                ],
411                responseType: UpdateStatusResponse.self
412            )
413            await invalidateAfterMutation()
414            // Re-fetch the ticket to get updated status/resolution
415            await reloadTicketPreservingDebugState()
416        } catch {
417            self.error = error.userFacingMessage
418        }
419
420        isPerformingAction = false
421    }
422
423    func assignUser(username: String) async {
424        guard !isPerformingAction else { return }
425        isPerformingAction = true
426        error = nil
427
428        do {
429            // Resolve username to user ID
430            let userResult = try await client.execute(
431                service: .todo,
432                query: Self.userLookupQuery,
433                variables: ["username": username],
434                responseType: UserLookupResponse.self
435            )
436            let userId = userResult.user.id
437
438            _ = try await client.execute(
439                service: .todo,
440                query: Self.assignUserMutation,
441                variables: [
442                    "trackerId": trackerId,
443                    "ticketId": ticketId,
444                    "userId": userId
445                ],
446                responseType: AssignUserResponse.self
447            )
448            await invalidateAfterMutation()
449            // Reload to reflect the change
450            await reloadTicketPreservingDebugState()
451        } catch {
452            self.error = error.userFacingMessage
453        }
454
455        isPerformingAction = false
456    }
457
458    func assignToCurrentUser(_ user: User) async {
459        guard !isPerformingAction, let currentTicket = ticket else { return }
460
461        let currentAssignees = currentTicket.assignees
462        let currentEntity = Entity(canonicalName: user.canonicalName)
463        guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
464            return
465        }
466
467        isPerformingAction = true
468        error = nil
469
470        ticket = TicketDetail(
471            id: currentTicket.id,
472            created: currentTicket.created,
473            updated: currentTicket.updated,
474            title: currentTicket.title,
475            description: currentTicket.description,
476            status: currentTicket.status,
477            resolution: currentTicket.resolution,
478            authenticity: currentTicket.authenticity,
479            submitter: currentTicket.submitter,
480            assignees: currentAssignees + [currentEntity],
481            labels: currentTicket.labels
482        )
483
484        do {
485            _ = try await client.execute(
486                service: .todo,
487                query: Self.assignUserMutation,
488                variables: [
489                    "trackerId": trackerId,
490                    "ticketId": ticketId,
491                    "userId": user.id
492                ],
493                responseType: AssignUserResponse.self
494            )
495            await invalidateAfterMutation()
496            await reloadTicketPreservingDebugState()
497        } catch {
498            ticket = TicketDetail(
499                id: currentTicket.id,
500                created: currentTicket.created,
501                updated: currentTicket.updated,
502                title: currentTicket.title,
503                description: currentTicket.description,
504                status: currentTicket.status,
505                resolution: currentTicket.resolution,
506                authenticity: currentTicket.authenticity,
507                submitter: currentTicket.submitter,
508                assignees: currentAssignees,
509                labels: currentTicket.labels
510            )
511            self.error = error.userFacingMessage
512        }
513
514        isPerformingAction = false
515    }
516
517    func unassignUser(username: String) async {
518        guard !isPerformingAction else { return }
519        isPerformingAction = true
520        error = nil
521
522        do {
523            // Resolve username to user ID
524            let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
525            let userResult = try await client.execute(
526                service: .todo,
527                query: Self.userLookupQuery,
528                variables: ["username": stripped],
529                responseType: UserLookupResponse.self
530            )
531            let userId = userResult.user.id
532
533            _ = try await client.execute(
534                service: .todo,
535                query: Self.unassignUserMutation,
536                variables: [
537                    "trackerId": trackerId,
538                    "ticketId": ticketId,
539                    "userId": userId
540                ],
541                responseType: UnassignUserResponse.self
542            )
543            await invalidateAfterMutation()
544            // Reload to reflect the change
545            await reloadTicketPreservingDebugState()
546        } catch {
547            self.error = error.userFacingMessage
548        }
549
550        isPerformingAction = false
551    }
552
553    func labelTicket(labelId: Int) async {
554        guard !isPerformingAction else { return }
555        isPerformingAction = true
556        error = nil
557
558        do {
559            _ = try await client.execute(
560                service: .todo,
561                query: Self.labelTicketMutation,
562                variables: [
563                    "trackerId": trackerId,
564                    "ticketId": ticketId,
565                    "labelId": labelId
566                ],
567                responseType: LabelTicketResponse.self
568            )
569            await invalidateAfterMutation()
570            await reloadTicketPreservingDebugState()
571        } catch {
572            self.error = error.userFacingMessage
573        }
574
575        isPerformingAction = false
576    }
577
578    func unlabelTicket(labelId: Int) async {
579        guard !isPerformingAction else { return }
580        isPerformingAction = true
581        error = nil
582
583        do {
584            _ = try await client.execute(
585                service: .todo,
586                query: Self.unlabelTicketMutation,
587                variables: [
588                    "trackerId": trackerId,
589                    "ticketId": ticketId,
590                    "labelId": labelId
591                ],
592                responseType: UnlabelTicketResponse.self
593            )
594            await invalidateAfterMutation()
595            await reloadTicketPreservingDebugState()
596        } catch {
597            self.error = error.userFacingMessage
598        }
599
600        isPerformingAction = false
601    }
602
603    func loadTrackerLabels() async {
604        do {
605            let result = try await client.execute(
606                service: .todo,
607                query: Self.trackerLabelsQuery,
608                variables: ["rid": trackerRid],
609                responseType: TrackerLabelsResponse.self
610            )
611            trackerLabels = result.tracker.labels.results
612        } catch {
613            self.error = error.userFacingMessage
614        }
615    }
616
617    func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
618        guard !isPerformingAction else { return }
619        isPerformingAction = true
620        error = nil
621
622        do {
623            let result = try await client.execute(
624                service: .todo,
625                query: Self.createLabelMutation,
626                variables: [
627                    "trackerId": trackerId,
628                    "name": name,
629                    "backgroundColor": backgroundColor,
630                    "foregroundColor": foregroundColor
631                ],
632                responseType: CreateLabelResponse.self
633            )
634            trackerLabels.append(result.createLabel)
635        } catch {
636            self.error = error.userFacingMessage
637        }
638
639        isPerformingAction = false
640    }
641
642    private func reloadTicketPreservingDebugState() async {
643        if rawTicketResponse != nil {
644            await loadTicketWithDebugCapture()
645        } else {
646            await loadTicket()
647        }
648    }
649
650    private func refreshTicketInBackground() async {
651        guard !isRefreshingCachedData else { return }
652        isRefreshingCachedData = true
653        defer { isRefreshingCachedData = false }
654
655        do {
656            let result = try await client.executeCached(
657                service: .todo,
658                query: Self.detailQuery,
659                variables: [
660                    "rid": trackerRid,
661                    "ticketId": ticketId
662                ],
663                responseType: TicketDetailResponse.self,
664                cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
665                resourceType: .ticketDetail,
666                ttl: APICacheTTLs.ticketDetail,
667                policy: .refreshIgnoringCache
668            )
669            apply(result.value, metadata: result.metadata)
670        } catch {
671            if ticket == nil {
672                self.error = error.userFacingMessage
673            }
674        }
675    }
676
677    private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
678        cacheMetadata = metadata
679        let payload = response.tracker.ticket
680        let updatedTicket = TicketDetail(
681            id: payload.id,
682            created: payload.created,
683            updated: payload.updated,
684            title: payload.title,
685            description: payload.description,
686            status: payload.status,
687            resolution: payload.resolution,
688            authenticity: payload.authenticity,
689            submitter: payload.submitter,
690            assignees: payload.assignees,
691            labels: payload.labels
692        )
693        ticket = updatedTicket
694        let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
695        events = updatedEvents
696    }
697
698    private func invalidateAfterMutation() async {
699        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
700        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
701        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
702        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
703    }
704
705    static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
706        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
707        let userCanonical = normalizedCanonicalName(user.canonicalName)
708        if assigneeCanonical == userCanonical {
709            return true
710        }
711        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
712    }
713
714    private static func normalizedCanonicalName(_ value: String) -> String {
715        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
716        if trimmed.hasPrefix("~") {
717            return trimmed
718        }
719        return "~\(trimmed)"
720    }
721
722    private static func normalizedUsername(_ value: String) -> String {
723        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
724        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
725    }
726
727}