krz/hutch

an ios client for sourcehut

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

v2.9.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
101    let ownerUsername: String
102    let trackerName: String
103    let trackerId: Int
104    let trackerRid: String
105    let ticketId: Int
106
107    private(set) var ticket: TicketDetail?
108    private(set) var events: [TicketEvent] = []
109    private(set) var isLoading = false
110    private(set) var isSubmitting = false
111    private(set) var isPerformingAction = false
112    private(set) var trackerLabels: [TicketLabel] = []
113    var commentText = ""
114    var error: String?
115
116    private let client: SRHTClient
117
118    private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
119        if lhs.created == rhs.created {
120            return lhs.id < rhs.id
121        }
122        return lhs.created < rhs.created
123    }
124
125    static func statusUpdateInput(
126        status: TicketStatus,
127        resolution: TicketResolution?
128    ) -> [String: any Sendable] {
129        var input: [String: any Sendable] = [
130            "status": status.rawValue
131        ]
132        if status == .resolved, let resolution {
133            input["resolution"] = resolution.rawValue
134        }
135        return input
136    }
137
138    init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
139        self.ownerUsername = ownerUsername
140        self.trackerName = trackerName
141        self.trackerId = trackerId
142        self.trackerRid = trackerRid
143        self.ticketId = ticketId
144        self.client = client
145    }
146
147    // MARK: - Queries
148
149    private static let detailQuery = """
150    query ticket($rid: ID!, $ticketId: Int!) {
151        tracker(rid: $rid) {
152            ticket(id: $ticketId) {
153                id
154                created
155                updated
156                title: subject
157                description: body
158                status
159                resolution
160                authenticity
161                submitter { canonicalName }
162                assignees { canonicalName }
163                labels { id name backgroundColor foregroundColor }
164                events {
165                    results {
166                        id
167                        created
168                        changes {
169                            eventType
170                            ... on Comment {
171                                author { canonicalName }
172                                text
173                                authenticity
174                            }
175                            ... on StatusChange {
176                                oldStatus
177                                newStatus
178                            }
179                            ... on LabelUpdate {
180                                labeler { canonicalName }
181                                label { name }
182                            }
183                            ... on Assignment {
184                                assigner { canonicalName }
185                                assignee { canonicalName }
186                            }
187                            ... on TicketMention {
188                                mentioned { id }
189                            }
190                            ... on UserMention {
191                                mentioned { canonicalName }
192                            }
193                            ... on Created {
194                                author { canonicalName }
195                            }
196                        }
197                    }
198                    cursor
199                }
200            }
201        }
202    }
203    """
204
205    private static let submitCommentMutation = """
206    mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
207        submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
208            id
209            created
210            changes {
211                eventType
212                ... on Comment {
213                    author { canonicalName }
214                    text
215                    authenticity
216                }
217            }
218        }
219    }
220    """
221
222    private static let updateStatusMutation = """
223    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
224        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
225            eventType: __typename
226        }
227    }
228    """
229
230    private static let assignUserMutation = """
231    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
232        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
233    }
234    """
235
236    private static let unassignUserMutation = """
237    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
238        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
239    }
240    """
241
242    private static let labelTicketMutation = """
243    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
244        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
245    }
246    """
247
248    private static let unlabelTicketMutation = """
249    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
250        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
251    }
252    """
253
254    private static let userLookupQuery = """
255    query userLookup($username: String!) {
256        user(username: $username) { id }
257    }
258    """
259
260    private static let trackerLabelsQuery = """
261    query trackerLabels($rid: ID!) {
262        tracker(rid: $rid) {
263            labels {
264                results { id name backgroundColor foregroundColor }
265            }
266        }
267    }
268    """
269
270    private static let createLabelMutation = """
271    mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
272        createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
273            id
274            name
275            backgroundColor
276            foregroundColor
277        }
278    }
279    """
280
281    // MARK: - Public API
282
283    func loadTicket() async {
284        guard !isLoading else { return }
285        isLoading = true
286        error = nil
287
288        do {
289            let result = try await client.execute(
290                service: .todo,
291                query: Self.detailQuery,
292                variables: [
293                    "rid": trackerRid,
294                    "ticketId": ticketId
295                ],
296                responseType: TicketDetailResponse.self
297            )
298            let payload = result.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.userFacingMessage
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.userFacingMessage
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? = nil) async {
364        guard !isPerformingAction else { return }
365        isPerformingAction = true
366        error = nil
367
368        do {
369            let input = Self.statusUpdateInput(status: status, resolution: resolution)
370            _ = try await client.execute(
371                service: .todo,
372                query: Self.updateStatusMutation,
373                variables: [
374                    "trackerId": trackerId,
375                    "ticketId": ticketId,
376                    "input": input
377                ],
378                responseType: UpdateStatusResponse.self
379            )
380            // Re-fetch the ticket to get updated status/resolution
381            await loadTicket()
382        } catch {
383            self.error = error.userFacingMessage
384        }
385
386        isPerformingAction = false
387    }
388
389    func assignUser(username: String) async {
390        guard !isPerformingAction else { return }
391        isPerformingAction = true
392        error = nil
393
394        do {
395            // Resolve username to user ID
396            let userResult = try await client.execute(
397                service: .todo,
398                query: Self.userLookupQuery,
399                variables: ["username": username],
400                responseType: UserLookupResponse.self
401            )
402            let userId = userResult.user.id
403
404            _ = try await client.execute(
405                service: .todo,
406                query: Self.assignUserMutation,
407                variables: [
408                    "trackerId": trackerId,
409                    "ticketId": ticketId,
410                    "userId": userId
411                ],
412                responseType: AssignUserResponse.self
413            )
414            // Reload to reflect the change
415            await loadTicket()
416        } catch {
417            self.error = error.userFacingMessage
418        }
419
420        isPerformingAction = false
421    }
422
423    func assignToCurrentUser(_ user: User) async {
424        guard !isPerformingAction, let currentTicket = ticket else { return }
425
426        let currentAssignees = currentTicket.assignees
427        let currentEntity = Entity(canonicalName: user.canonicalName)
428        guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
429            return
430        }
431
432        isPerformingAction = true
433        error = nil
434
435        ticket = TicketDetail(
436            id: currentTicket.id,
437            created: currentTicket.created,
438            updated: currentTicket.updated,
439            title: currentTicket.title,
440            description: currentTicket.description,
441            status: currentTicket.status,
442            resolution: currentTicket.resolution,
443            authenticity: currentTicket.authenticity,
444            submitter: currentTicket.submitter,
445            assignees: currentAssignees + [currentEntity],
446            labels: currentTicket.labels
447        )
448
449        do {
450            _ = try await client.execute(
451                service: .todo,
452                query: Self.assignUserMutation,
453                variables: [
454                    "trackerId": trackerId,
455                    "ticketId": ticketId,
456                    "userId": user.id
457                ],
458                responseType: AssignUserResponse.self
459            )
460            await loadTicket()
461        } catch {
462            ticket = TicketDetail(
463                id: currentTicket.id,
464                created: currentTicket.created,
465                updated: currentTicket.updated,
466                title: currentTicket.title,
467                description: currentTicket.description,
468                status: currentTicket.status,
469                resolution: currentTicket.resolution,
470                authenticity: currentTicket.authenticity,
471                submitter: currentTicket.submitter,
472                assignees: currentAssignees,
473                labels: currentTicket.labels
474            )
475            self.error = error.userFacingMessage
476        }
477
478        isPerformingAction = false
479    }
480
481    func unassignUser(username: String) async {
482        guard !isPerformingAction else { return }
483        isPerformingAction = true
484        error = nil
485
486        do {
487            // Resolve username to user ID
488            let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
489            let userResult = try await client.execute(
490                service: .todo,
491                query: Self.userLookupQuery,
492                variables: ["username": stripped],
493                responseType: UserLookupResponse.self
494            )
495            let userId = userResult.user.id
496
497            _ = try await client.execute(
498                service: .todo,
499                query: Self.unassignUserMutation,
500                variables: [
501                    "trackerId": trackerId,
502                    "ticketId": ticketId,
503                    "userId": userId
504                ],
505                responseType: UnassignUserResponse.self
506            )
507            // Reload to reflect the change
508            await loadTicket()
509        } catch {
510            self.error = error.userFacingMessage
511        }
512
513        isPerformingAction = false
514    }
515
516    func labelTicket(labelId: Int) async {
517        guard !isPerformingAction else { return }
518        isPerformingAction = true
519        error = nil
520
521        do {
522            _ = try await client.execute(
523                service: .todo,
524                query: Self.labelTicketMutation,
525                variables: [
526                    "trackerId": trackerId,
527                    "ticketId": ticketId,
528                    "labelId": labelId
529                ],
530                responseType: LabelTicketResponse.self
531            )
532            await loadTicket()
533        } catch {
534            self.error = error.userFacingMessage
535        }
536
537        isPerformingAction = false
538    }
539
540    func unlabelTicket(labelId: Int) async {
541        guard !isPerformingAction else { return }
542        isPerformingAction = true
543        error = nil
544
545        do {
546            _ = try await client.execute(
547                service: .todo,
548                query: Self.unlabelTicketMutation,
549                variables: [
550                    "trackerId": trackerId,
551                    "ticketId": ticketId,
552                    "labelId": labelId
553                ],
554                responseType: UnlabelTicketResponse.self
555            )
556            await loadTicket()
557        } catch {
558            self.error = error.userFacingMessage
559        }
560
561        isPerformingAction = false
562    }
563
564    func loadTrackerLabels() async {
565        do {
566            let result = try await client.execute(
567                service: .todo,
568                query: Self.trackerLabelsQuery,
569                variables: ["rid": trackerRid],
570                responseType: TrackerLabelsResponse.self
571            )
572            trackerLabels = result.tracker.labels.results
573        } catch {
574            self.error = error.userFacingMessage
575        }
576    }
577
578    func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
579        guard !isPerformingAction else { return }
580        isPerformingAction = true
581        error = nil
582
583        do {
584            let result = try await client.execute(
585                service: .todo,
586                query: Self.createLabelMutation,
587                variables: [
588                    "trackerId": trackerId,
589                    "name": name,
590                    "backgroundColor": backgroundColor,
591                    "foregroundColor": foregroundColor
592                ],
593                responseType: CreateLabelResponse.self
594            )
595            trackerLabels.append(result.createLabel)
596        } catch {
597            self.error = error.userFacingMessage
598        }
599
600        isPerformingAction = false
601    }
602
603    static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
604        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
605        let userCanonical = normalizedCanonicalName(user.canonicalName)
606        if assigneeCanonical == userCanonical {
607            return true
608        }
609        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
610    }
611
612    private static func normalizedCanonicalName(_ value: String) -> String {
613        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
614        if trimmed.hasPrefix("~") {
615            return trimmed
616        }
617        return "~\(trimmed)"
618    }
619
620    private static func normalizedUsername(_ value: String) -> String {
621        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
622        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
623    }
624
625}