krz/hutch

an ios client for sourcehut

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

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