krz/hutch

an ios client for sourcehut

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

v3.2.0: 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        "ticket.detail.\(ownerUsername).\(trackerRid).\(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    var commentText = ""
118    var error: String?
119
120    private let client: SRHTClient
121
122    private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
123        if lhs.created == rhs.created {
124            return lhs.id < rhs.id
125        }
126        return lhs.created < rhs.created
127    }
128
129    static func statusUpdateInput(
130        status: TicketStatus,
131        resolution: TicketResolution?
132    ) -> [String: any Sendable] {
133        var input: [String: any Sendable] = [
134            "status": status.rawValue
135        ]
136        if status == .resolved, let resolution {
137            input["resolution"] = resolution.rawValue
138        }
139        return input
140    }
141
142    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
143        self.ownerUsername = ownerUsername
144        self.trackerName = trackerName
145        self.trackerId = trackerId
146        self.trackerRid = trackerRid
147        self.ticketId = ticketId
148        self.client = client
149    }
150
151    // MARK: - Queries
152
153    private static let detailQuery = """
154    query ticket($rid: ID!, $ticketId: Int!) {
155        tracker(rid: $rid) {
156            ticket(id: $ticketId) {
157                id
158                created
159                updated
160                title: subject
161                description: body
162                status
163                resolution
164                authenticity
165                submitter { canonicalName }
166                assignees { canonicalName }
167                labels { id name backgroundColor foregroundColor }
168                events {
169                    results {
170                        id
171                        created
172                        changes {
173                            eventType
174                            ... on Comment {
175                                author { canonicalName }
176                                text
177                                authenticity
178                            }
179                            ... on StatusChange {
180                                oldStatus
181                                newStatus
182                            }
183                            ... on LabelUpdate {
184                                labeler { canonicalName }
185                                label { name }
186                            }
187                            ... on Assignment {
188                                assigner { canonicalName }
189                                assignee { canonicalName }
190                            }
191                            ... on TicketMention {
192                                mentioned { id }
193                            }
194                            ... on UserMention {
195                                mentioned { canonicalName }
196                            }
197                            ... on Created {
198                                author { canonicalName }
199                            }
200                        }
201                    }
202                    cursor
203                }
204            }
205        }
206    }
207    """
208
209    private static let submitCommentMutation = """
210    mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
211        submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
212            id
213            created
214            changes {
215                eventType
216                ... on Comment {
217                    author { canonicalName }
218                    text
219                    authenticity
220                }
221            }
222        }
223    }
224    """
225
226    private static let updateStatusMutation = """
227    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
228        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
229            eventType: __typename
230        }
231    }
232    """
233
234    private static let assignUserMutation = """
235    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
236        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
237    }
238    """
239
240    private static let unassignUserMutation = """
241    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
242        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
243    }
244    """
245
246    private static let labelTicketMutation = """
247    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
248        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
249    }
250    """
251
252    private static let unlabelTicketMutation = """
253    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
254        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
255    }
256    """
257
258    private static let userLookupQuery = """
259    query userLookup($username: String!) {
260        user(username: $username) { id }
261    }
262    """
263
264    private static let trackerLabelsQuery = """
265    query trackerLabels($rid: ID!) {
266        tracker(rid: $rid) {
267            labels {
268                results { id name backgroundColor foregroundColor }
269            }
270        }
271    }
272    """
273
274    private static let createLabelMutation = """
275    mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
276        createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
277            id
278            name
279            backgroundColor
280            foregroundColor
281        }
282    }
283    """
284
285    // MARK: - Public API
286
287    func loadTicket() async {
288        guard !isLoading else { return }
289        isLoading = true
290        error = nil
291        rawTicketResponse = nil
292
293        do {
294            let result = try await client.execute(
295                service: .todo,
296                query: Self.detailQuery,
297                variables: [
298                    "rid": trackerRid,
299                    "ticketId": ticketId
300                ],
301                responseType: TicketDetailResponse.self
302            )
303            let payload = result.tracker.ticket
304            ticket = TicketDetail(
305                id: payload.id,
306                created: payload.created,
307                updated: payload.updated,
308                title: payload.title,
309                description: payload.description,
310                status: payload.status,
311                resolution: payload.resolution,
312                authenticity: payload.authenticity,
313                submitter: payload.submitter,
314                assignees: payload.assignees,
315                labels: payload.labels
316            )
317            events = payload.events.results.sorted(by: Self.timelineOrder)
318        } catch {
319            self.error = error.userFacingMessage
320        }
321
322        isLoading = false
323    }
324
325    func loadTicketWithDebugCapture() async {
326        guard !isLoading else { return }
327        isLoading = true
328        error = nil
329
330        do {
331            let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
332            let result = try await client.executeAndCache(
333                service: .todo,
334                query: Self.detailQuery,
335                variables: [
336                    "rid": trackerRid,
337                    "ticketId": ticketId
338                ],
339                responseType: TicketDetailResponse.self,
340                cacheKey: cacheKey
341            )
342            rawTicketResponse = client.responseCache.get(forKey: cacheKey)
343                .flatMap { String(data: $0, encoding: .utf8) }
344            let payload = result.tracker.ticket
345            ticket = TicketDetail(
346                id: payload.id,
347                created: payload.created,
348                updated: payload.updated,
349                title: payload.title,
350                description: payload.description,
351                status: payload.status,
352                resolution: payload.resolution,
353                authenticity: payload.authenticity,
354                submitter: payload.submitter,
355                assignees: payload.assignees,
356                labels: payload.labels
357            )
358            events = payload.events.results.sorted(by: Self.timelineOrder)
359        } catch {
360            self.error = error.userFacingMessage
361        }
362
363        isLoading = false
364    }
365
366    func submitComment() async {
367        let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
368        guard !text.isEmpty, !isSubmitting else { return }
369        isSubmitting = true
370        error = nil
371
372        do {
373            let input: [String: any Sendable] = ["text": text]
374            let result = try await client.execute(
375                service: .todo,
376                query: Self.submitCommentMutation,
377                variables: [
378                    "trackerId": trackerId,
379                    "ticketId": ticketId,
380                    "input": input
381                ],
382                responseType: SubmitCommentResponse.self
383            )
384            // Append the returned event so the comment shows immediately.
385            let submitted = result.submitComment
386            let event = TicketEvent(
387                id: submitted.id,
388                created: submitted.created,
389                changes: submitted.changes
390            )
391            events.append(event)
392            events.sort(by: Self.timelineOrder)
393            commentText = ""
394        } catch {
395            self.error = error.userFacingMessage
396        }
397
398        isSubmitting = false
399    }
400    
401    func updateComment(commentId: Int, text: String) async {
402        _ = commentId
403        _ = text
404        error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
405    }
406
407    // MARK: - Ticket Actions
408
409    func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
410        guard !isPerformingAction else { return }
411        isPerformingAction = true
412        error = nil
413
414        do {
415            let input = Self.statusUpdateInput(status: status, resolution: resolution)
416            _ = try await client.execute(
417                service: .todo,
418                query: Self.updateStatusMutation,
419                variables: [
420                    "trackerId": trackerId,
421                    "ticketId": ticketId,
422                    "input": input
423                ],
424                responseType: UpdateStatusResponse.self
425            )
426            // Re-fetch the ticket to get updated status/resolution
427            await reloadTicketPreservingDebugState()
428        } catch {
429            self.error = error.userFacingMessage
430        }
431
432        isPerformingAction = false
433    }
434
435    func assignUser(username: String) async {
436        guard !isPerformingAction else { return }
437        isPerformingAction = true
438        error = nil
439
440        do {
441            // Resolve username to user ID
442            let userResult = try await client.execute(
443                service: .todo,
444                query: Self.userLookupQuery,
445                variables: ["username": username],
446                responseType: UserLookupResponse.self
447            )
448            let userId = userResult.user.id
449
450            _ = try await client.execute(
451                service: .todo,
452                query: Self.assignUserMutation,
453                variables: [
454                    "trackerId": trackerId,
455                    "ticketId": ticketId,
456                    "userId": userId
457                ],
458                responseType: AssignUserResponse.self
459            )
460            // Reload to reflect the change
461            await reloadTicketPreservingDebugState()
462        } catch {
463            self.error = error.userFacingMessage
464        }
465
466        isPerformingAction = false
467    }
468
469    func assignToCurrentUser(_ user: User) async {
470        guard !isPerformingAction, let currentTicket = ticket else { return }
471
472        let currentAssignees = currentTicket.assignees
473        let currentEntity = Entity(canonicalName: user.canonicalName)
474        guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
475            return
476        }
477
478        isPerformingAction = true
479        error = nil
480
481        ticket = TicketDetail(
482            id: currentTicket.id,
483            created: currentTicket.created,
484            updated: currentTicket.updated,
485            title: currentTicket.title,
486            description: currentTicket.description,
487            status: currentTicket.status,
488            resolution: currentTicket.resolution,
489            authenticity: currentTicket.authenticity,
490            submitter: currentTicket.submitter,
491            assignees: currentAssignees + [currentEntity],
492            labels: currentTicket.labels
493        )
494
495        do {
496            _ = try await client.execute(
497                service: .todo,
498                query: Self.assignUserMutation,
499                variables: [
500                    "trackerId": trackerId,
501                    "ticketId": ticketId,
502                    "userId": user.id
503                ],
504                responseType: AssignUserResponse.self
505            )
506            await reloadTicketPreservingDebugState()
507        } catch {
508            ticket = TicketDetail(
509                id: currentTicket.id,
510                created: currentTicket.created,
511                updated: currentTicket.updated,
512                title: currentTicket.title,
513                description: currentTicket.description,
514                status: currentTicket.status,
515                resolution: currentTicket.resolution,
516                authenticity: currentTicket.authenticity,
517                submitter: currentTicket.submitter,
518                assignees: currentAssignees,
519                labels: currentTicket.labels
520            )
521            self.error = error.userFacingMessage
522        }
523
524        isPerformingAction = false
525    }
526
527    func unassignUser(username: String) async {
528        guard !isPerformingAction else { return }
529        isPerformingAction = true
530        error = nil
531
532        do {
533            // Resolve username to user ID
534            let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
535            let userResult = try await client.execute(
536                service: .todo,
537                query: Self.userLookupQuery,
538                variables: ["username": stripped],
539                responseType: UserLookupResponse.self
540            )
541            let userId = userResult.user.id
542
543            _ = try await client.execute(
544                service: .todo,
545                query: Self.unassignUserMutation,
546                variables: [
547                    "trackerId": trackerId,
548                    "ticketId": ticketId,
549                    "userId": userId
550                ],
551                responseType: UnassignUserResponse.self
552            )
553            // Reload to reflect the change
554            await reloadTicketPreservingDebugState()
555        } catch {
556            self.error = error.userFacingMessage
557        }
558
559        isPerformingAction = false
560    }
561
562    func labelTicket(labelId: Int) async {
563        guard !isPerformingAction else { return }
564        isPerformingAction = true
565        error = nil
566
567        do {
568            _ = try await client.execute(
569                service: .todo,
570                query: Self.labelTicketMutation,
571                variables: [
572                    "trackerId": trackerId,
573                    "ticketId": ticketId,
574                    "labelId": labelId
575                ],
576                responseType: LabelTicketResponse.self
577            )
578            await reloadTicketPreservingDebugState()
579        } catch {
580            self.error = error.userFacingMessage
581        }
582
583        isPerformingAction = false
584    }
585
586    func unlabelTicket(labelId: Int) async {
587        guard !isPerformingAction else { return }
588        isPerformingAction = true
589        error = nil
590
591        do {
592            _ = try await client.execute(
593                service: .todo,
594                query: Self.unlabelTicketMutation,
595                variables: [
596                    "trackerId": trackerId,
597                    "ticketId": ticketId,
598                    "labelId": labelId
599                ],
600                responseType: UnlabelTicketResponse.self
601            )
602            await reloadTicketPreservingDebugState()
603        } catch {
604            self.error = error.userFacingMessage
605        }
606
607        isPerformingAction = false
608    }
609
610    func loadTrackerLabels() async {
611        do {
612            let result = try await client.execute(
613                service: .todo,
614                query: Self.trackerLabelsQuery,
615                variables: ["rid": trackerRid],
616                responseType: TrackerLabelsResponse.self
617            )
618            trackerLabels = result.tracker.labels.results
619        } catch {
620            self.error = error.userFacingMessage
621        }
622    }
623
624    func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
625        guard !isPerformingAction else { return }
626        isPerformingAction = true
627        error = nil
628
629        do {
630            let result = try await client.execute(
631                service: .todo,
632                query: Self.createLabelMutation,
633                variables: [
634                    "trackerId": trackerId,
635                    "name": name,
636                    "backgroundColor": backgroundColor,
637                    "foregroundColor": foregroundColor
638                ],
639                responseType: CreateLabelResponse.self
640            )
641            trackerLabels.append(result.createLabel)
642        } catch {
643            self.error = error.userFacingMessage
644        }
645
646        isPerformingAction = false
647    }
648
649    private func reloadTicketPreservingDebugState() async {
650        if rawTicketResponse != nil {
651            await loadTicketWithDebugCapture()
652        } else {
653            await loadTicket()
654        }
655    }
656
657    static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
658        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
659        let userCanonical = normalizedCanonicalName(user.canonicalName)
660        if assigneeCanonical == userCanonical {
661            return true
662        }
663        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
664    }
665
666    private static func normalizedCanonicalName(_ value: String) -> String {
667        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
668        if trimmed.hasPrefix("~") {
669            return trimmed
670        }
671        return "~\(trimmed)"
672    }
673
674    private static func normalizedUsername(_ value: String) -> String {
675        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
676        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
677    }
678
679}