krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.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 /// Null when the authenticated user is not subscribed to this ticket.
23 let subscription: SubscriptionIdPayload?
24 let submitter: Entity
25 let assignees: [Entity]
26 let labels: [TicketLabel]
27 let events: EventsPage
28}
29
30struct SubscriptionIdPayload: Decodable, Sendable {
31 let id: Int
32}
33
34private struct EventsPage: Decodable, Sendable {
35 let results: [TicketEvent]
36 let cursor: String?
37}
38
39private struct SubmitCommentResponse: Decodable, Sendable {
40 let submitComment: SubmittedEvent
41}
42
43private struct SubmittedEvent: Decodable, Sendable {
44 let id: Int
45 let created: Date
46 let changes: [EventChange]
47}
48
49private struct MutationEventResponse: Decodable, Sendable {
50 let id: Int
51}
52
53private struct UpdateStatusResponse: Decodable, Sendable {
54 let updateTicketStatus: UpdatedStatusEvent
55}
56
57private struct UpdatedStatusEvent: Decodable, Sendable {
58 let eventType: String
59}
60
61private struct TicketSubscriptionResponse: Decodable, Sendable {
62 let subscription: SubscriptionIdPayload
63}
64
65private struct UpdateTicketResponse: Decodable, Sendable {
66 let updateTicket: TicketIdPayload
67}
68
69private struct DeleteTicketResponse: Decodable, Sendable {
70 let deleteTicket: TicketIdPayload
71}
72
73private struct TicketIdPayload: Decodable, Sendable {
74 let id: Int
75}
76
77private struct AssignUserResponse: Decodable, Sendable {
78 let assignUser: MutationEventResponse
79}
80
81private struct UnassignUserResponse: Decodable, Sendable {
82 let unassignUser: MutationEventResponse
83}
84
85private struct LabelTicketResponse: Decodable, Sendable {
86 let labelTicket: MutationEventResponse
87}
88
89private struct UnlabelTicketResponse: Decodable, Sendable {
90 let unlabelTicket: MutationEventResponse
91}
92
93private struct UserLookupResponse: Decodable, Sendable {
94 let user: UserIdPayload
95}
96
97private struct UserIdPayload: Decodable, Sendable {
98 let id: Int
99}
100
101private struct CreateLabelResponse: Decodable, Sendable {
102 let createLabel: TicketLabel
103}
104
105private struct TrackerLabelsResponse: Decodable, Sendable {
106 let tracker: TrackerLabelsWrapper
107}
108
109private struct TrackerLabelsWrapper: Decodable, Sendable {
110 let labels: LabelsPage
111}
112
113private struct LabelsPage: Decodable, Sendable {
114 let results: [TicketLabel]
115}
116
117// MARK: - View Model
118
119@Observable
120@MainActor
121final class TicketDetailViewModel {
122 private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
123 APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
124 }
125
126 let ownerUsername: String
127 let trackerName: String
128 let trackerId: Int
129 let trackerRid: String
130 let ticketId: Int
131
132 private(set) var ticket: TicketDetail?
133 private(set) var events: [TicketEvent] = []
134 private(set) var isLoading = false
135 private(set) var isSubmitting = false
136 private(set) var isPerformingAction = false
137 /// Whether the authenticated user receives email for this ticket. Mirrors
138 /// `Ticket.subscription`, which is null when not subscribed.
139 private(set) var isSubscribed = false
140 private(set) var trackerLabels: [TicketLabel] = []
141 private(set) var rawTicketResponse: String?
142 private(set) var cacheMetadata: CacheEntryMetadata?
143 private(set) var isRefreshingCachedData = false
144 var commentText = ""
145 var error: String?
146
147 private let client: SRHTClient
148
149 private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
150 if lhs.created == rhs.created {
151 return lhs.id < rhs.id
152 }
153 return lhs.created < rhs.created
154 }
155
156 static func statusUpdateInput(
157 status: TicketStatus,
158 resolution: TicketResolution?
159 ) -> [String: any Sendable] {
160 var input: [String: any Sendable] = [
161 "status": status.rawValue
162 ]
163 if status == .resolved, let resolution {
164 input["resolution"] = resolution.rawValue
165 }
166 return input
167 }
168
169 /// Builds an `UpdateTicketInput` carrying only the fields that changed, so an
170 /// edit never overwrites a field the user did not touch.
171 static func ticketUpdateInput(
172 subject: String,
173 body: String,
174 currentSubject: String,
175 currentBody: String?
176 ) -> [String: any Sendable] {
177 var input: [String: any Sendable] = [:]
178 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
179 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
180
181 if trimmedSubject != currentSubject {
182 input["subject"] = trimmedSubject
183 }
184
185 if trimmedBody != (currentBody ?? "") {
186 if trimmedBody.isEmpty {
187 // A nil subscript assignment would drop the key and leave the old
188 // body in place instead of clearing it.
189 input.updateValue(Optional<String>.none as any Sendable, forKey: "body")
190 } else {
191 input["body"] = trimmedBody
192 }
193 }
194
195 return input
196 }
197
198 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
199 self.ownerUsername = ownerUsername
200 self.trackerName = trackerName
201 self.trackerId = trackerId
202 self.trackerRid = trackerRid
203 self.ticketId = ticketId
204 self.client = client
205 }
206
207 // MARK: - Queries
208
209 private static let detailQuery = """
210 query ticket($rid: ID!, $ticketId: Int!) {
211 tracker(rid: $rid) {
212 ticket(id: $ticketId) {
213 id
214 created
215 updated
216 title: subject
217 description: body
218 status
219 resolution
220 authenticity
221 subscription { id }
222 submitter { canonicalName }
223 assignees { canonicalName }
224 labels { id name backgroundColor foregroundColor }
225 events {
226 results {
227 id
228 created
229 changes {
230 eventType
231 ... on Comment {
232 author { canonicalName }
233 text
234 authenticity
235 }
236 ... on StatusChange {
237 oldStatus
238 newStatus
239 }
240 ... on LabelUpdate {
241 labeler { canonicalName }
242 label { name }
243 }
244 ... on Assignment {
245 assigner { canonicalName }
246 assignee { canonicalName }
247 }
248 ... on TicketMention {
249 mentioned { id }
250 }
251 ... on UserMention {
252 mentioned { canonicalName }
253 }
254 ... on Created {
255 author { canonicalName }
256 }
257 }
258 }
259 cursor
260 }
261 }
262 }
263 }
264 """
265
266 private static let submitCommentMutation = """
267 mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
268 submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
269 id
270 created
271 changes {
272 eventType
273 ... on Comment {
274 author { canonicalName }
275 text
276 authenticity
277 }
278 }
279 }
280 }
281 """
282
283 private static let updateStatusMutation = """
284 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
285 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
286 eventType: __typename
287 }
288 }
289 """
290
291 private static let updateTicketMutation = """
292 mutation updateTicket($trackerId: Int!, $ticketId: Int!, $input: UpdateTicketInput!) {
293 updateTicket(trackerId: $trackerId, ticketId: $ticketId, input: $input) { id }
294 }
295 """
296
297 private static let deleteTicketMutation = """
298 mutation deleteTicket($trackerId: Int!, $ticketId: Int!) {
299 deleteTicket(trackerId: $trackerId, ticketId: $ticketId) { id }
300 }
301 """
302
303 private static let ticketSubscribeMutation = """
304 mutation ticketSubscribe($trackerId: Int!, $ticketId: Int!) {
305 subscription: ticketSubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
306 }
307 """
308
309 private static let ticketUnsubscribeMutation = """
310 mutation ticketUnsubscribe($trackerId: Int!, $ticketId: Int!) {
311 subscription: ticketUnsubscribe(trackerId: $trackerId, ticketId: $ticketId) { id }
312 }
313 """
314
315 private static let assignUserMutation = """
316 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
317 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
318 }
319 """
320
321 private static let unassignUserMutation = """
322 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
323 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
324 }
325 """
326
327 private static let labelTicketMutation = """
328 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
329 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
330 }
331 """
332
333 private static let unlabelTicketMutation = """
334 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
335 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
336 }
337 """
338
339 private static let userLookupQuery = """
340 query userLookup($username: String!) {
341 user(username: $username) { id }
342 }
343 """
344
345 private static let trackerLabelsQuery = """
346 query trackerLabels($rid: ID!) {
347 tracker(rid: $rid) {
348 labels {
349 results { id name backgroundColor foregroundColor }
350 }
351 }
352 }
353 """
354
355 private static let createLabelMutation = """
356 mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
357 createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
358 id
359 name
360 backgroundColor
361 foregroundColor
362 }
363 }
364 """
365
366 // MARK: - Public API
367
368 func loadTicket() async {
369 guard !isLoading else { return }
370 isLoading = true
371 error = nil
372 rawTicketResponse = nil
373
374 do {
375 let result = try await client.executeCached(
376 service: .todo,
377 query: Self.detailQuery,
378 variables: [
379 "rid": trackerRid,
380 "ticketId": ticketId
381 ],
382 responseType: TicketDetailResponse.self,
383 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
384 resourceType: .ticketDetail,
385 ttl: APICacheTTLs.ticketDetail,
386 policy: .cacheFirstThenRefresh
387 )
388 apply(result.value, metadata: result.metadata)
389 if result.isFromCache {
390 isLoading = false
391 await refreshTicketInBackground()
392 return
393 }
394 } catch {
395 self.error = error.userFacingMessage
396 }
397
398 isLoading = false
399 }
400
401 func loadTicketWithDebugCapture() async {
402 guard !isLoading else { return }
403 isLoading = true
404 error = nil
405
406 do {
407 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
408 let result = try await client.executeCached(
409 service: .todo,
410 query: Self.detailQuery,
411 variables: [
412 "rid": trackerRid,
413 "ticketId": ticketId
414 ],
415 responseType: TicketDetailResponse.self,
416 cacheKey: cacheKey,
417 resourceType: .ticketDetail,
418 ttl: APICacheTTLs.ticketDetail,
419 policy: .refreshIgnoringCache
420 )
421 rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
422 .flatMap { String(data: $0, encoding: .utf8) }
423 apply(result.value, metadata: result.metadata)
424 } catch {
425 self.error = error.userFacingMessage
426 }
427
428 isLoading = false
429 }
430
431 func submitComment() async {
432 let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
433 guard !text.isEmpty, !isSubmitting else { return }
434 isSubmitting = true
435 error = nil
436
437 do {
438 let input: [String: any Sendable] = ["text": text]
439 let result = try await client.execute(
440 service: .todo,
441 query: Self.submitCommentMutation,
442 variables: [
443 "trackerId": trackerId,
444 "ticketId": ticketId,
445 "input": input
446 ],
447 responseType: SubmitCommentResponse.self
448 )
449 // Append the returned event so the comment shows immediately.
450 let submitted = result.submitComment
451 let event = TicketEvent(
452 id: submitted.id,
453 created: submitted.created,
454 changes: submitted.changes
455 )
456 events.append(event)
457 events.sort(by: Self.timelineOrder)
458 commentText = ""
459 await invalidateAfterMutation()
460 } catch {
461 self.error = error.userFacingMessage
462 }
463
464 isSubmitting = false
465 }
466
467 func updateComment(commentId: Int, text: String) async {
468 _ = commentId
469 _ = text
470 error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
471 }
472
473 // MARK: - Ticket Actions
474
475 func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
476 guard !isPerformingAction else { return }
477 isPerformingAction = true
478 error = nil
479
480 do {
481 let input = Self.statusUpdateInput(status: status, resolution: resolution)
482 _ = try await client.execute(
483 service: .todo,
484 query: Self.updateStatusMutation,
485 variables: [
486 "trackerId": trackerId,
487 "ticketId": ticketId,
488 "input": input
489 ],
490 responseType: UpdateStatusResponse.self
491 )
492 await invalidateAfterMutation()
493 // Re-fetch the ticket to get updated status/resolution
494 await reloadTicketPreservingDebugState()
495 } catch {
496 self.error = error.userFacingMessage
497 }
498
499 isPerformingAction = false
500 }
501
502 /// Edits the ticket's subject and body. Returns true when the edit was sent,
503 /// including the no-op case where nothing changed.
504 @discardableResult
505 func updateTicket(subject: String, body: String) async -> Bool {
506 guard !isPerformingAction, let ticket else { return false }
507
508 let input = Self.ticketUpdateInput(
509 subject: subject,
510 body: body,
511 currentSubject: ticket.title,
512 currentBody: ticket.description
513 )
514 guard !input.isEmpty else { return true }
515
516 isPerformingAction = true
517 error = nil
518 defer { isPerformingAction = false }
519
520 do {
521 _ = try await client.execute(
522 service: .todo,
523 query: Self.updateTicketMutation,
524 variables: [
525 "trackerId": trackerId,
526 "ticketId": ticketId,
527 "input": input
528 ],
529 responseType: UpdateTicketResponse.self
530 )
531 await invalidateAfterMutation()
532 await reloadTicketPreservingDebugState()
533 return true
534 } catch {
535 self.error = error.userFacingMessage
536 return false
537 }
538 }
539
540 /// Subscribes to or unsubscribes from email notifications for this ticket.
541 func toggleSubscription() async {
542 guard !isPerformingAction else { return }
543 isPerformingAction = true
544 error = nil
545 defer { isPerformingAction = false }
546
547 let wasSubscribed = isSubscribed
548 // Reflect the change immediately; the catch below puts it back if the
549 // mutation fails, so the control never lies about server state.
550 isSubscribed.toggle()
551
552 do {
553 _ = try await client.execute(
554 service: .todo,
555 query: wasSubscribed ? Self.ticketUnsubscribeMutation : Self.ticketSubscribeMutation,
556 variables: [
557 "trackerId": trackerId,
558 "ticketId": ticketId
559 ],
560 responseType: TicketSubscriptionResponse.self
561 )
562 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
563 } catch {
564 isSubscribed = wasSubscribed
565 self.error = error.userFacingMessage
566 }
567 }
568
569 /// Deletes the ticket. Returns true on success so the caller can pop the view.
570 @discardableResult
571 func deleteTicket() async -> Bool {
572 guard !isPerformingAction else { return false }
573 isPerformingAction = true
574 error = nil
575 defer { isPerformingAction = false }
576
577 do {
578 _ = try await client.execute(
579 service: .todo,
580 query: Self.deleteTicketMutation,
581 variables: [
582 "trackerId": trackerId,
583 "ticketId": ticketId
584 ],
585 responseType: DeleteTicketResponse.self
586 )
587 await invalidateAfterMutation()
588 return true
589 } catch {
590 self.error = error.userFacingMessage
591 return false
592 }
593 }
594
595 func assignUser(username: String) async {
596 guard !isPerformingAction else { return }
597 isPerformingAction = true
598 error = nil
599
600 do {
601 // Resolve username to user ID
602 let userResult = try await client.execute(
603 service: .todo,
604 query: Self.userLookupQuery,
605 variables: ["username": username],
606 responseType: UserLookupResponse.self
607 )
608 let userId = userResult.user.id
609
610 _ = try await client.execute(
611 service: .todo,
612 query: Self.assignUserMutation,
613 variables: [
614 "trackerId": trackerId,
615 "ticketId": ticketId,
616 "userId": userId
617 ],
618 responseType: AssignUserResponse.self
619 )
620 await invalidateAfterMutation()
621 // Reload to reflect the change
622 await reloadTicketPreservingDebugState()
623 } catch {
624 self.error = error.userFacingMessage
625 }
626
627 isPerformingAction = false
628 }
629
630 func assignToCurrentUser(_ user: User) async {
631 guard !isPerformingAction, let currentTicket = ticket else { return }
632
633 let currentAssignees = currentTicket.assignees
634 let currentEntity = Entity(canonicalName: user.canonicalName)
635 guard !currentAssignees.contains(where: { Self.matchesAssignee($0, user: user) }) else {
636 return
637 }
638
639 isPerformingAction = true
640 error = nil
641
642 ticket = TicketDetail(
643 id: currentTicket.id,
644 created: currentTicket.created,
645 updated: currentTicket.updated,
646 title: currentTicket.title,
647 description: currentTicket.description,
648 status: currentTicket.status,
649 resolution: currentTicket.resolution,
650 authenticity: currentTicket.authenticity,
651 submitter: currentTicket.submitter,
652 assignees: currentAssignees + [currentEntity],
653 labels: currentTicket.labels
654 )
655
656 do {
657 _ = try await client.execute(
658 service: .todo,
659 query: Self.assignUserMutation,
660 variables: [
661 "trackerId": trackerId,
662 "ticketId": ticketId,
663 "userId": user.id
664 ],
665 responseType: AssignUserResponse.self
666 )
667 await invalidateAfterMutation()
668 await reloadTicketPreservingDebugState()
669 } catch {
670 ticket = TicketDetail(
671 id: currentTicket.id,
672 created: currentTicket.created,
673 updated: currentTicket.updated,
674 title: currentTicket.title,
675 description: currentTicket.description,
676 status: currentTicket.status,
677 resolution: currentTicket.resolution,
678 authenticity: currentTicket.authenticity,
679 submitter: currentTicket.submitter,
680 assignees: currentAssignees,
681 labels: currentTicket.labels
682 )
683 self.error = error.userFacingMessage
684 }
685
686 isPerformingAction = false
687 }
688
689 func unassignUser(username: String) async {
690 guard !isPerformingAction else { return }
691 isPerformingAction = true
692 error = nil
693
694 do {
695 // Resolve username to user ID
696 let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
697 let userResult = try await client.execute(
698 service: .todo,
699 query: Self.userLookupQuery,
700 variables: ["username": stripped],
701 responseType: UserLookupResponse.self
702 )
703 let userId = userResult.user.id
704
705 _ = try await client.execute(
706 service: .todo,
707 query: Self.unassignUserMutation,
708 variables: [
709 "trackerId": trackerId,
710 "ticketId": ticketId,
711 "userId": userId
712 ],
713 responseType: UnassignUserResponse.self
714 )
715 await invalidateAfterMutation()
716 // Reload to reflect the change
717 await reloadTicketPreservingDebugState()
718 } catch {
719 self.error = error.userFacingMessage
720 }
721
722 isPerformingAction = false
723 }
724
725 func labelTicket(labelId: Int) async {
726 guard !isPerformingAction else { return }
727 isPerformingAction = true
728 error = nil
729
730 do {
731 _ = try await client.execute(
732 service: .todo,
733 query: Self.labelTicketMutation,
734 variables: [
735 "trackerId": trackerId,
736 "ticketId": ticketId,
737 "labelId": labelId
738 ],
739 responseType: LabelTicketResponse.self
740 )
741 await invalidateAfterMutation()
742 await reloadTicketPreservingDebugState()
743 } catch {
744 self.error = error.userFacingMessage
745 }
746
747 isPerformingAction = false
748 }
749
750 func unlabelTicket(labelId: Int) async {
751 guard !isPerformingAction else { return }
752 isPerformingAction = true
753 error = nil
754
755 do {
756 _ = try await client.execute(
757 service: .todo,
758 query: Self.unlabelTicketMutation,
759 variables: [
760 "trackerId": trackerId,
761 "ticketId": ticketId,
762 "labelId": labelId
763 ],
764 responseType: UnlabelTicketResponse.self
765 )
766 await invalidateAfterMutation()
767 await reloadTicketPreservingDebugState()
768 } catch {
769 self.error = error.userFacingMessage
770 }
771
772 isPerformingAction = false
773 }
774
775 func loadTrackerLabels() async {
776 do {
777 let result = try await client.execute(
778 service: .todo,
779 query: Self.trackerLabelsQuery,
780 variables: ["rid": trackerRid],
781 responseType: TrackerLabelsResponse.self
782 )
783 trackerLabels = result.tracker.labels.results
784 } catch {
785 self.error = error.userFacingMessage
786 }
787 }
788
789 func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
790 guard !isPerformingAction else { return }
791 isPerformingAction = true
792 error = nil
793
794 do {
795 let result = try await client.execute(
796 service: .todo,
797 query: Self.createLabelMutation,
798 variables: [
799 "trackerId": trackerId,
800 "name": name,
801 "backgroundColor": backgroundColor,
802 "foregroundColor": foregroundColor
803 ],
804 responseType: CreateLabelResponse.self
805 )
806 trackerLabels.append(result.createLabel)
807 } catch {
808 self.error = error.userFacingMessage
809 }
810
811 isPerformingAction = false
812 }
813
814 private func reloadTicketPreservingDebugState() async {
815 if rawTicketResponse != nil {
816 await loadTicketWithDebugCapture()
817 } else {
818 await loadTicket()
819 }
820 }
821
822 private func refreshTicketInBackground() async {
823 guard !isRefreshingCachedData else { return }
824 isRefreshingCachedData = true
825 defer { isRefreshingCachedData = false }
826
827 do {
828 let result = try await client.executeCached(
829 service: .todo,
830 query: Self.detailQuery,
831 variables: [
832 "rid": trackerRid,
833 "ticketId": ticketId
834 ],
835 responseType: TicketDetailResponse.self,
836 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
837 resourceType: .ticketDetail,
838 ttl: APICacheTTLs.ticketDetail,
839 policy: .refreshIgnoringCache
840 )
841 apply(result.value, metadata: result.metadata)
842 } catch {
843 if ticket == nil {
844 self.error = error.userFacingMessage
845 }
846 }
847 }
848
849 private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
850 cacheMetadata = metadata
851 let payload = response.tracker.ticket
852 let updatedTicket = TicketDetail(
853 id: payload.id,
854 created: payload.created,
855 updated: payload.updated,
856 title: payload.title,
857 description: payload.description,
858 status: payload.status,
859 resolution: payload.resolution,
860 authenticity: payload.authenticity,
861 submitter: payload.submitter,
862 assignees: payload.assignees,
863 labels: payload.labels
864 )
865 ticket = updatedTicket
866 isSubscribed = payload.subscription != nil
867 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
868 events = updatedEvents
869 }
870
871 private func invalidateAfterMutation() async {
872 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
873 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
874 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
875 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
876 }
877
878 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
879 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
880 let userCanonical = normalizedCanonicalName(user.canonicalName)
881 if assigneeCanonical == userCanonical {
882 return true
883 }
884 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
885 }
886
887 private static func normalizedCanonicalName(_ value: String) -> String {
888 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
889 if trimmed.hasPrefix("~") {
890 return trimmed
891 }
892 return "~\(trimmed)"
893 }
894
895 private static func normalizedUsername(_ value: String) -> String {
896 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
897 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
898 }
899
900}