krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.3.1: Hutch/Views/Tickets/TicketListViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackerTicketsResponse: Decodable, Sendable {
6 let tracker: TrackerTicketsWrapper
7}
8
9private struct TrackerTicketsWrapper: Decodable, Sendable {
10 let tickets: TicketsPage
11}
12
13private struct TicketsPage: Decodable, Sendable {
14 let results: [TicketSummary]
15 let cursor: String?
16}
17
18private struct AssignmentMutationResponse: Decodable, Sendable {
19 struct EventRef: Decodable, Sendable {
20 let id: Int
21 }
22
23 let assignUser: EventRef?
24 let unassignUser: EventRef?
25}
26
27private struct LabelMutationResponse: Decodable, Sendable {
28 struct EventRef: Decodable, Sendable {
29 let id: Int
30 }
31
32 let labelTicket: EventRef?
33 let unlabelTicket: EventRef?
34}
35
36private struct TrackerLabelsResponse: Decodable, Sendable {
37 let tracker: TrackerLabelsWrapper
38}
39
40private struct TrackerLabelsWrapper: Decodable, Sendable {
41 let labels: LabelsPage
42}
43
44private struct LabelsPage: Decodable, Sendable {
45 let results: [TicketLabel]
46}
47
48private struct UpdateStatusResponse: Decodable, Sendable {
49 let updateTicketStatus: MutationEventRef
50}
51
52private struct MutationEventRef: Decodable, Sendable {
53 let eventType: String
54}
55
56private struct TicketListUserLookupResponse: Decodable, Sendable {
57 let user: TicketListUserIDPayload?
58}
59
60private struct TicketListUserIDPayload: Decodable, Sendable {
61 let id: Int
62}
63
64// MARK: - Filter
65
66enum TicketFilter: String, CaseIterable, Codable, Sendable {
67 case open = "Open"
68 case resolved = "Resolved"
69 case all = "All"
70}
71
72// MARK: - View Model
73
74@Observable
75@MainActor
76final class TicketListViewModel {
77 private static func searchHistoryScopeID(for trackerRid: String) -> String {
78 "tickets.\(trackerRid)"
79 }
80
81 let ownerUsername: String
82 let trackerName: String
83 let trackerId: Int
84 let trackerRid: String
85
86 private(set) var tickets: [TicketSummary] = [] {
87 didSet { updateFilteredTickets() }
88 }
89 private(set) var isLoading = false
90 private(set) var isLoadingMore = false
91 private(set) var isCreatingTicket = false
92 private(set) var isPerformingAction = false
93 private(set) var trackerLabels: [TicketLabel] = []
94 private(set) var recentSearches: [ScopedSearchHistoryEntry]
95 private(set) var savedFilters: [SavedTicketFilter]
96 private(set) var isSelectionMode = false
97 private(set) var selectedTicketIDs: Set<Int> = []
98 var error: String?
99 var filter: TicketFilter = .open {
100 didSet {
101 persistFilterState()
102 resetPaginationAndUpdateFilters()
103 }
104 }
105 var selectedLabelIDs: Set<Int> = [] {
106 didSet {
107 persistFilterState()
108 resetPaginationAndUpdateFilters()
109 }
110 }
111 var searchText = "" {
112 didSet { updateFilteredTickets() }
113 }
114 private(set) var activeSavedFilterID: SavedTicketFilter.ID?
115 // Cached filtered result. See updateFilteredTickets().
116 private(set) var filteredTickets: [TicketSummary] = []
117
118 private var cursor: String?
119 private var hasMore = true
120 private let client: SRHTClient
121 private let defaults: UserDefaults
122
123 init(
124 ownerUsername: String,
125 trackerName: String,
126 trackerId: Int,
127 trackerRid: String,
128 client: SRHTClient,
129 defaults: UserDefaults = .standard
130 ) {
131 self.ownerUsername = ownerUsername
132 self.trackerName = trackerName
133 self.trackerId = trackerId
134 self.trackerRid = trackerRid
135 self.client = client
136 self.defaults = defaults
137
138 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
139 let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
140 self.filter = restoredState.status
141 self.selectedLabelIDs = Set(restoredState.labelIDs)
142 self.savedFilters = savedFilters
143 self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id
144 self.recentSearches = ScopedSearchHistoryStore.load(
145 scopeID: Self.searchHistoryScopeID(for: trackerRid),
146 defaults: defaults
147 )
148 }
149
150 // MARK: - Query
151
152 private static let query = """
153 query tickets($rid: ID!, $cursor: Cursor) {
154 tracker(rid: $rid) {
155 tickets(cursor: $cursor) {
156 results {
157 id
158 title: subject
159 status
160 resolution
161 created
162 submitter { canonicalName }
163 labels { id name backgroundColor foregroundColor }
164 assignees { canonicalName }
165 }
166 cursor
167 }
168 }
169 }
170 """
171
172 private static let submitTicketMutation = """
173 mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
174 submitTicket(trackerId: $trackerId, input: $input) {
175 id
176 title: subject
177 status
178 resolution
179 created
180 submitter { canonicalName }
181 labels { id name backgroundColor foregroundColor }
182 assignees { canonicalName }
183 }
184 }
185 """
186
187 private static let updateStatusMutation = """
188 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
189 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
190 eventType: __typename
191 }
192 }
193 """
194
195 private static let assignUserMutation = """
196 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
197 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
198 }
199 """
200
201 private static let unassignUserMutation = """
202 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
203 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
204 }
205 """
206
207 private static let labelTicketMutation = """
208 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
209 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
210 }
211 """
212
213 private static let unlabelTicketMutation = """
214 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
215 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
216 }
217 """
218
219 private static let trackerLabelsQuery = """
220 query trackerLabels($rid: ID!) {
221 tracker(rid: $rid) {
222 labels {
223 results { id name backgroundColor foregroundColor }
224 }
225 }
226 }
227 """
228
229 private static let userLookupQuery = """
230 query userLookup($username: String!) {
231 user(username: $username) { id }
232 }
233 """
234
235 // MARK: - Computed
236
237 var currentFilterState: TicketListFilterState {
238 TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
239 }
240
241 var availableLabels: [TicketLabel] {
242 let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
243 let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
244 partialResult[label.id] = label
245 }
246 return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
247 }
248
249 var selectedLabels: [TicketLabel] {
250 availableLabels.filter { selectedLabelIDs.contains($0.id) }
251 }
252
253 var selectedTickets: [TicketSummary] {
254 tickets.filter { selectedTicketIDs.contains($0.id) }
255 }
256
257 var selectedTicketCount: Int {
258 selectedTicketIDs.count
259 }
260
261 var suggestedSavedFilterName: String {
262 let labelNames = selectedLabels.map(\.name).sorted()
263 var components: [String] = []
264
265 if filter != .open || !labelNames.isEmpty {
266 components.append(filter.rawValue)
267 }
268 if !labelNames.isEmpty {
269 components.append(labelNames.joined(separator: ", "))
270 }
271
272 return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ")
273 }
274
275 var hasCustomFilterSelection: Bool {
276 !currentFilterState.isDefault
277 }
278
279 private func updateFilteredTickets() {
280 let updated = Self.filterTickets(tickets, state: currentFilterState, query: searchText)
281 if updated != filteredTickets {
282 filteredTickets = updated
283 }
284 }
285
286 private func resetPaginationAndUpdateFilters() {
287 // Reset pagination when filters change since the cursor is tied to the unfiltered dataset
288 cursor = nil
289 hasMore = true
290 updateFilteredTickets()
291 }
292
293 // MARK: - Public API
294
295 func loadTickets() async {
296 isLoading = true
297 error = nil
298 cursor = nil
299 hasMore = true
300
301 do {
302 if tickets.isEmpty, let cachedPage = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
303 tickets = cachedPage.results
304 cursor = cachedPage.cursor
305 hasMore = cachedPage.cursor != nil
306 reconcileSelectionWithLoadedTickets()
307 isLoading = false
308 }
309 // todo.sr.ht exposes `tickets(cursor:)` only (see Docs/API/todo.json) — no server-side
310 // status filter. The Open tab filters client-side, so we paginate until the cursor is
311 // exhausted; otherwise older open tickets never appear in the first page (25 items).
312 var accumulated: [TicketSummary] = []
313 var nextCursor: String?
314
315 repeat {
316 let page = try await fetchPage(cursor: nextCursor)
317
318 let existingIDs = Set(accumulated.map(\.id))
319 let newTickets = page.results.filter { !existingIDs.contains($0.id) }
320
321 if newTickets.isEmpty && !page.results.isEmpty {
322 break
323 }
324
325 accumulated.append(contentsOf: newTickets)
326 nextCursor = page.cursor
327 } while nextCursor != nil
328
329 tickets = accumulated
330 cursor = nextCursor
331 hasMore = nextCursor != nil
332 reconcileSelectionWithLoadedTickets()
333 } catch {
334 if !Task.isCancelled {
335 self.error = error.userFacingMessage
336 }
337 }
338
339 isLoading = false
340 }
341
342 func loadMoreIfNeeded(currentItem: TicketSummary) async {
343 // Check if currentItem is in the filtered list and close to the end
344 guard hasMore, !isLoadingMore else { return }
345
346 guard let index = filteredTickets.firstIndex(where: { $0.id == currentItem.id }) else {
347 return
348 }
349
350 let itemsFromEnd = filteredTickets.count - index - 1
351 guard itemsFromEnd < 5 else { return }
352
353 isLoadingMore = true
354
355 do {
356 let page = try await fetchPage(cursor: cursor)
357
358 // Deduplicate: only add tickets that don't already exist
359 let existingIDs = Set(tickets.map(\.id))
360 let newTickets = page.results.filter { !existingIDs.contains($0.id) }
361
362 // If we got back the same tickets, the API cursor pagination isn't working
363 if newTickets.isEmpty && !page.results.isEmpty {
364 hasMore = false
365 }
366
367 cursor = page.cursor
368 tickets.append(contentsOf: newTickets)
369 hasMore = page.cursor != nil
370 reconcileSelectionWithLoadedTickets()
371 } catch {
372 // Ignore cancellation errors — the SwiftUI .task modifier cancels in-flight
373 // requests when a row scrolls off-screen, which is expected behavior.
374 if !Task.isCancelled {
375 self.error = error.userFacingMessage
376 }
377 }
378
379 isLoadingMore = false
380 }
381
382 func createTicket(subject: String, body: String) async -> TicketSummary? {
383 guard !isCreatingTicket else { return nil }
384
385 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
386 guard !trimmedSubject.isEmpty else {
387 error = "Enter a ticket title."
388 return nil
389 }
390
391 isCreatingTicket = true
392 error = nil
393 defer { isCreatingTicket = false }
394
395 var input: [String: any Sendable] = [
396 "subject": trimmedSubject
397 ]
398 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
399 if !trimmedBody.isEmpty {
400 input["body"] = trimmedBody
401 }
402 let variables: [String: any Sendable] = [
403 "trackerId": trackerId,
404 "input": input
405 ]
406
407 do {
408 let result = try await client.execute(
409 service: .todo,
410 query: Self.submitTicketMutation,
411 variables: variables,
412 responseType: SubmitTicketResponse.self
413 )
414 let ticket = result.submitTicket
415 await invalidateTicketCaches()
416 tickets.insert(ticket, at: 0)
417 return ticket
418 } catch {
419 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
420 return nil
421 }
422 }
423
424 func resolveTicket(_ ticket: TicketSummary) async {
425 let input: [String: any Sendable] = [
426 "status": TicketStatus.resolved.rawValue,
427 "resolution": TicketResolution.fixed.rawValue
428 ]
429 await performStatusUpdate(ticket: ticket, input: input)
430 }
431
432 func reopenTicket(_ ticket: TicketSummary) async {
433 let input: [String: any Sendable] = [
434 "status": TicketStatus.reported.rawValue
435 ]
436 await performStatusUpdate(ticket: ticket, input: input)
437 }
438
439 func assignToMe(ticket: TicketSummary, user: User) async {
440 guard !isPerformingAction else { return }
441 isPerformingAction = true
442 error = nil
443
444 let original = tickets
445 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
446 let entity = Entity(canonicalName: user.canonicalName)
447 let updated = TicketSummary(
448 id: ticket.id,
449 title: ticket.title,
450 status: ticket.status,
451 resolution: ticket.resolution,
452 created: ticket.created,
453 submitter: ticket.submitter,
454 labels: ticket.labels,
455 assignees: ticket.assignees + [entity]
456 )
457 tickets[index] = updated
458 }
459
460 do {
461 _ = try await client.execute(
462 service: .todo,
463 query: Self.assignUserMutation,
464 variables: [
465 "trackerId": trackerId,
466 "ticketId": ticket.id,
467 "userId": user.id
468 ],
469 responseType: AssignmentMutationResponse.self
470 )
471 await invalidateTicketCaches()
472 } catch {
473 tickets = original
474 self.error = error.userFacingMessage
475 }
476
477 isPerformingAction = false
478 }
479
480 func unassignFromMe(ticket: TicketSummary, user: User) async {
481 guard !isPerformingAction else { return }
482 isPerformingAction = true
483 error = nil
484
485 let original = tickets
486 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
487 let filtered = ticket.assignees.filter { assignee in
488 !Self.matchesAssignee(assignee, user: user)
489 }
490 let updated = TicketSummary(
491 id: ticket.id,
492 title: ticket.title,
493 status: ticket.status,
494 resolution: ticket.resolution,
495 created: ticket.created,
496 submitter: ticket.submitter,
497 labels: ticket.labels,
498 assignees: filtered
499 )
500 tickets[index] = updated
501 }
502
503 do {
504 _ = try await client.execute(
505 service: .todo,
506 query: Self.unassignUserMutation,
507 variables: [
508 "trackerId": trackerId,
509 "ticketId": ticket.id,
510 "userId": user.id
511 ],
512 responseType: AssignmentMutationResponse.self
513 )
514 await invalidateTicketCaches()
515 } catch {
516 tickets = original
517 self.error = error.userFacingMessage
518 }
519
520 isPerformingAction = false
521 }
522
523 func loadTrackerLabels() async {
524 do {
525 let cached = try await client.executeCached(
526 service: .todo,
527 query: Self.trackerLabelsQuery,
528 variables: ["rid": trackerRid],
529 responseType: TrackerLabelsResponse.self,
530 cacheKey: APICacheKeys.trackerLabels(trackerRid: trackerRid),
531 resourceType: .ticketList,
532 ttl: APICacheTTLs.ticketList,
533 policy: .cacheFirstThenRefresh
534 )
535 syncTrackerLabels(cached.value.tracker.labels.results)
536 } catch {
537 self.error = error.userFacingMessage
538 }
539 }
540
541 func syncTrackerLabels(_ labels: [TicketLabel]) {
542 trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
543 tickets = Self.synchronizeTickets(tickets, with: trackerLabels)
544 selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels)
545 }
546
547 func toggleLabelSelection(_ label: TicketLabel) {
548 if selectedLabelIDs.contains(label.id) {
549 selectedLabelIDs.remove(label.id)
550 } else {
551 selectedLabelIDs.insert(label.id)
552 }
553 }
554
555 func clearLabelSelection() {
556 selectedLabelIDs = []
557 }
558
559 func setSelectionMode(_ enabled: Bool) {
560 isSelectionMode = enabled
561 if !enabled {
562 clearTicketSelection()
563 }
564 }
565
566 func toggleTicketSelection(_ ticket: TicketSummary) {
567 if selectedTicketIDs.contains(ticket.id) {
568 selectedTicketIDs.remove(ticket.id)
569 } else {
570 selectedTicketIDs.insert(ticket.id)
571 }
572 }
573
574 func selectVisibleTickets(_ tickets: [TicketSummary]) {
575 selectedTicketIDs = Set(tickets.map(\.id))
576 }
577
578 func clearTicketSelection() {
579 selectedTicketIDs = []
580 }
581
582 func resetFilters() {
583 filter = .open
584 selectedLabelIDs = []
585 }
586
587 func recordRecentSearch(_ query: String) {
588 ScopedSearchHistoryStore.record(
589 query: query,
590 scopeID: Self.searchHistoryScopeID(for: trackerRid),
591 defaults: defaults
592 )
593 recentSearches = ScopedSearchHistoryStore.load(
594 scopeID: Self.searchHistoryScopeID(for: trackerRid),
595 defaults: defaults
596 )
597 }
598
599 func clearRecentSearches() {
600 ScopedSearchHistoryStore.clear(
601 scopeID: Self.searchHistoryScopeID(for: trackerRid),
602 defaults: defaults
603 )
604 recentSearches = []
605 }
606
607 func applySavedFilter(_ savedFilter: SavedTicketFilter) {
608 filter = savedFilter.state.status
609 selectedLabelIDs = Set(savedFilter.state.labelIDs)
610 activeSavedFilterID = savedFilter.id
611 }
612
613 func saveCurrentFilter(named name: String) {
614 guard let savedFilter = TicketSavedFilterStore.saveFilter(
615 named: name,
616 state: currentFilterState,
617 for: trackerRid,
618 defaults: defaults
619 ) else {
620 return
621 }
622
623 savedFilters.removeAll {
624 $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
625 }
626 savedFilters.insert(savedFilter, at: 0)
627 activeSavedFilterID = savedFilter.id
628 }
629
630 func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
631 TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
632 savedFilters.removeAll { $0.id == savedFilter.id }
633 if activeSavedFilterID == savedFilter.id {
634 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
635 }
636 }
637
638 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
639 guard !isPerformingAction else { return }
640 isPerformingAction = true
641 error = nil
642
643 let original = tickets
644 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
645 let updated = TicketSummary(
646 id: ticket.id,
647 title: ticket.title,
648 status: ticket.status,
649 resolution: ticket.resolution,
650 created: ticket.created,
651 submitter: ticket.submitter,
652 labels: ticket.labels + [label],
653 assignees: ticket.assignees
654 )
655 tickets[index] = updated
656 }
657
658 do {
659 _ = try await client.execute(
660 service: .todo,
661 query: Self.labelTicketMutation,
662 variables: [
663 "trackerId": trackerId,
664 "ticketId": ticket.id,
665 "labelId": label.id
666 ],
667 responseType: LabelMutationResponse.self
668 )
669 await invalidateTicketCaches()
670 } catch {
671 tickets = original
672 self.error = error.userFacingMessage
673 }
674
675 isPerformingAction = false
676 }
677
678 func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
679 guard !isPerformingAction else { return }
680 isPerformingAction = true
681 error = nil
682
683 let original = tickets
684 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
685 let filtered = ticket.labels.filter { $0.id != label.id }
686 let updated = TicketSummary(
687 id: ticket.id,
688 title: ticket.title,
689 status: ticket.status,
690 resolution: ticket.resolution,
691 created: ticket.created,
692 submitter: ticket.submitter,
693 labels: filtered,
694 assignees: ticket.assignees
695 )
696 tickets[index] = updated
697 }
698
699 do {
700 _ = try await client.execute(
701 service: .todo,
702 query: Self.unlabelTicketMutation,
703 variables: [
704 "trackerId": trackerId,
705 "ticketId": ticket.id,
706 "labelId": label.id
707 ],
708 responseType: LabelMutationResponse.self
709 )
710 await invalidateTicketCaches()
711 } catch {
712 tickets = original
713 self.error = error.userFacingMessage
714 }
715
716 isPerformingAction = false
717 }
718
719 func ticket(withId ticketId: Int) -> TicketSummary? {
720 tickets.first(where: { $0.id == ticketId })
721 }
722
723 func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? {
724 await performBulkAction(
725 kind: .close,
726 prepare: { ticket in
727 guard ticket.status != .resolved else { return .unchanged }
728
729 let input = Self.bulkStatusUpdateInput(resolution: resolution)
730 let updatedTicket = updatedTicket(from: ticket, input: input)
731 return .request(updatedTicket: updatedTicket) {
732 try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input)
733 }
734 }
735 )
736 }
737
738 func assignSelectedTickets(username: String) async -> TicketBulkActionResult? {
739 let normalizedUsername = Self.normalizedUsername(username)
740 guard !normalizedUsername.isEmpty else {
741 error = "Enter a SourceHut username."
742 return nil
743 }
744
745 do {
746 let userResult = try await client.execute(
747 service: .todo,
748 query: Self.userLookupQuery,
749 variables: ["username": normalizedUsername],
750 responseType: TicketListUserLookupResponse.self
751 )
752 guard let userID = userResult.user?.id else {
753 error = "That user couldn’t be found."
754 return nil
755 }
756
757 let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername))
758 return await performBulkAction(
759 kind: .assign,
760 prepare: { ticket in
761 guard !ticket.assignees.contains(where: {
762 Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName
763 }) else {
764 return .unchanged
765 }
766
767 let updatedTicket = TicketSummary(
768 id: ticket.id,
769 title: ticket.title,
770 status: ticket.status,
771 resolution: ticket.resolution,
772 created: ticket.created,
773 submitter: ticket.submitter,
774 labels: ticket.labels,
775 assignees: ticket.assignees + [assignee]
776 )
777
778 return .request(updatedTicket: updatedTicket) {
779 try await self.executeBulkAssign(ticketID: ticket.id, userID: userID)
780 }
781 }
782 )
783 } catch {
784 self.error = error.userFacingMessage
785 return nil
786 }
787 }
788
789 // MARK: - Private
790
791 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
792 guard !isPerformingAction else { return }
793 isPerformingAction = true
794 error = nil
795
796 do {
797 let variables: [String: any Sendable] = [
798 "trackerId": trackerId,
799 "ticketId": ticket.id,
800 "input": input
801 ]
802 let result = try await client.execute(
803 service: .todo,
804 query: Self.updateStatusMutation,
805 variables: variables,
806 responseType: UpdateStatusResponse.self
807 )
808 _ = result.updateTicketStatus
809 await invalidateTicketCaches()
810 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
811 tickets[index] = updatedTicket(from: ticket, input: input)
812 }
813 } catch {
814 self.error = error.userFacingMessage
815 }
816
817 isPerformingAction = false
818 }
819
820 private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TicketsPage {
821 var variables: [String: any Sendable] = ["rid": trackerRid]
822 if let cursor {
823 variables["cursor"] = cursor
824 }
825 let cached = try await client.executeCached(
826 service: .todo,
827 query: Self.query,
828 variables: variables,
829 responseType: TrackerTicketsResponse.self,
830 cacheKey: APICacheKeys.tickets(trackerRid: trackerRid, cursor: cursor),
831 resourceType: .ticketList,
832 ttl: APICacheTTLs.ticketList,
833 policy: policy
834 )
835 return cached.value.tracker.tickets
836 }
837
838 private struct SubmitTicketResponse: Decodable, Sendable {
839 let submitTicket: TicketSummary
840 }
841
842 private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
843 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
844 let userCanonical = normalizedCanonicalName(user.canonicalName)
845 if assigneeCanonical == userCanonical {
846 return true
847 }
848 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
849 }
850
851 private static func normalizedCanonicalName(_ value: String) -> String {
852 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
853 if trimmed.hasPrefix("~") {
854 return trimmed
855 }
856 return "~\(trimmed)"
857 }
858
859 private static func normalizedUsername(_ value: String) -> String {
860 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
861 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
862 }
863
864 private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
865 let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
866 let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
867
868 return TicketSummary(
869 id: ticket.id,
870 title: ticket.title,
871 status: updatedStatus,
872 resolution: updatedStatus == .resolved ? updatedResolution : nil,
873 created: ticket.created,
874 submitter: ticket.submitter,
875 labels: ticket.labels,
876 assignees: ticket.assignees
877 )
878 }
879
880 private func persistFilterState() {
881 TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
882 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
883 }
884
885 private func reconcileSelectionWithLoadedTickets() {
886 let loadedTicketIDs = Set(tickets.map(\.id))
887 selectedTicketIDs.formIntersection(loadedTicketIDs)
888 if isSelectionMode, selectedTicketIDs.isEmpty {
889 isSelectionMode = false
890 }
891 }
892
893 private func performBulkAction(
894 kind: TicketBulkActionKind,
895 prepare: (TicketSummary) -> TicketBulkTicketOperation
896 ) async -> TicketBulkActionResult? {
897 guard !isPerformingAction else { return nil }
898
899 let selected = selectedTickets
900 guard !selected.isEmpty else { return nil }
901
902 isPerformingAction = true
903 error = nil
904
905 var updatedCount = 0
906 var unchangedCount = 0
907 var failures: [TicketBulkActionFailure] = []
908 var failedTicketIDs = Set<Int>()
909
910 for ticket in selected {
911 switch prepare(ticket) {
912 case .unchanged:
913 unchangedCount += 1
914 case .request(let updatedTicket, let request):
915 replaceTicket(updatedTicket)
916 do {
917 try await request()
918 updatedCount += 1
919 } catch {
920 replaceTicket(ticket)
921 failedTicketIDs.insert(ticket.id)
922 failures.append(
923 TicketBulkActionFailure(
924 ticketID: ticket.id,
925 message: error.userFacingMessage
926 )
927 )
928 }
929 }
930 }
931
932 isPerformingAction = false
933
934 let result = TicketBulkActionResult(
935 action: kind,
936 totalCount: selected.count,
937 updatedCount: updatedCount,
938 unchangedCount: unchangedCount,
939 failures: failures
940 )
941
942 if failedTicketIDs.isEmpty {
943 await invalidateTicketCaches()
944 clearTicketSelection()
945 isSelectionMode = false
946 } else {
947 selectedTicketIDs = failedTicketIDs
948 isSelectionMode = true
949 }
950
951 return result
952 }
953
954 private func replaceTicket(_ ticket: TicketSummary) {
955 guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return }
956 tickets[index] = ticket
957 }
958
959 private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws {
960 _ = try await client.execute(
961 service: .todo,
962 query: Self.updateStatusMutation,
963 variables: [
964 "trackerId": trackerId,
965 "ticketId": ticketID,
966 "input": input
967 ],
968 responseType: UpdateStatusResponse.self
969 )
970 }
971
972 private func executeBulkAssign(ticketID: Int, userID: Int) async throws {
973 _ = try await client.execute(
974 service: .todo,
975 query: Self.assignUserMutation,
976 variables: [
977 "trackerId": trackerId,
978 "ticketId": ticketID,
979 "userId": userID
980 ],
981 responseType: AssignmentMutationResponse.self
982 )
983 }
984
985 private func invalidateTicketCaches() async {
986 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
987 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
988 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
989 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
990 }
991
992 private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
993 [
994 "status": TicketStatus.resolved.rawValue,
995 "resolution": resolution.rawValue
996 ]
997 }
998
999 static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] {
1000 let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) })
1001
1002 return tickets.map { ticket in
1003 let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] }
1004 return TicketSummary(
1005 id: ticket.id,
1006 title: ticket.title,
1007 status: ticket.status,
1008 resolution: ticket.resolution,
1009 created: ticket.created,
1010 submitter: ticket.submitter,
1011 labels: updatedLabels,
1012 assignees: ticket.assignees
1013 )
1014 }
1015 }
1016
1017 static func reconciledSelectedLabelIDs(
1018 _ selectedLabelIDs: Set<Int>,
1019 availableLabels: [TicketLabel]
1020 ) -> Set<Int> {
1021 selectedLabelIDs.intersection(Set(availableLabels.map(\.id)))
1022 }
1023
1024 static func filterTickets(
1025 _ tickets: [TicketSummary],
1026 state: TicketListFilterState,
1027 query: String
1028 ) -> [TicketSummary] {
1029 let statusFiltered: [TicketSummary]
1030 switch state.status {
1031 case .open:
1032 statusFiltered = tickets.filter { $0.status.isOpen }
1033 case .resolved:
1034 statusFiltered = tickets.filter { !$0.status.isOpen }
1035 case .all:
1036 statusFiltered = tickets
1037 }
1038
1039 let labelFiltered: [TicketSummary]
1040 if state.labelIDs.isEmpty {
1041 labelFiltered = statusFiltered
1042 } else {
1043 let selectedLabelIDs = Set(state.labelIDs)
1044 labelFiltered = statusFiltered.filter { ticket in
1045 !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
1046 }
1047 }
1048
1049 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1050 guard !q.isEmpty else { return labelFiltered }
1051 return labelFiltered.filter {
1052 String($0.id).contains(q) ||
1053 $0.title.lowercased().contains(q) ||
1054 $0.submitter.canonicalName.lowercased().contains(q) ||
1055 $0.labels.contains { $0.name.lowercased().contains(q) } ||
1056 $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
1057 $0.status.displayName.lowercased().contains(q)
1058 }
1059 }
1060}
1061
1062private enum TicketBulkTicketOperation {
1063 case unchanged
1064 case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void)
1065}