krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.0.4: 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 updateFilteredTickets()
103 }
104 }
105 var selectedLabelIDs: Set<Int> = [] {
106 didSet {
107 persistFilterState()
108 updateFilteredTickets()
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 // MARK: - Public API
287
288 func loadTickets() async {
289 isLoading = true
290 error = nil
291 cursor = nil
292 hasMore = true
293
294 do {
295 let page = try await fetchPage(cursor: nil)
296 tickets = page.results
297 cursor = page.cursor
298 hasMore = page.cursor != nil
299 reconcileSelectionWithLoadedTickets()
300 } catch {
301 self.error = error.userFacingMessage
302 }
303
304 isLoading = false
305 }
306
307 func loadMoreIfNeeded(currentItem: TicketSummary) async {
308 guard let last = tickets.last,
309 last.id == currentItem.id,
310 hasMore,
311 !isLoadingMore else {
312 return
313 }
314
315 isLoadingMore = true
316
317 do {
318 let page = try await fetchPage(cursor: cursor)
319 tickets.append(contentsOf: page.results)
320 cursor = page.cursor
321 hasMore = page.cursor != nil
322 reconcileSelectionWithLoadedTickets()
323 } catch {
324 self.error = error.userFacingMessage
325 }
326
327 isLoadingMore = false
328 }
329
330 func createTicket(subject: String, body: String) async -> TicketSummary? {
331 guard !isCreatingTicket else { return nil }
332
333 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
334 guard !trimmedSubject.isEmpty else {
335 error = "Enter a ticket title."
336 return nil
337 }
338
339 isCreatingTicket = true
340 error = nil
341 defer { isCreatingTicket = false }
342
343 var input: [String: any Sendable] = [
344 "subject": trimmedSubject
345 ]
346 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
347 if !trimmedBody.isEmpty {
348 input["body"] = trimmedBody
349 }
350 let variables: [String: any Sendable] = [
351 "trackerId": trackerId,
352 "input": input
353 ]
354
355 do {
356 let result = try await client.execute(
357 service: .todo,
358 query: Self.submitTicketMutation,
359 variables: variables,
360 responseType: SubmitTicketResponse.self
361 )
362 let ticket = result.submitTicket
363 tickets.insert(ticket, at: 0)
364 return ticket
365 } catch {
366 self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
367 return nil
368 }
369 }
370
371 func resolveTicket(_ ticket: TicketSummary) async {
372 let input: [String: any Sendable] = [
373 "status": TicketStatus.resolved.rawValue,
374 "resolution": TicketResolution.fixed.rawValue
375 ]
376 await performStatusUpdate(ticket: ticket, input: input)
377 }
378
379 func reopenTicket(_ ticket: TicketSummary) async {
380 let input: [String: any Sendable] = [
381 "status": TicketStatus.reported.rawValue
382 ]
383 await performStatusUpdate(ticket: ticket, input: input)
384 }
385
386 func assignToMe(ticket: TicketSummary, user: User) async {
387 guard !isPerformingAction else { return }
388 isPerformingAction = true
389 error = nil
390
391 let original = tickets
392 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
393 let entity = Entity(canonicalName: user.canonicalName)
394 let updated = TicketSummary(
395 id: ticket.id,
396 title: ticket.title,
397 status: ticket.status,
398 resolution: ticket.resolution,
399 created: ticket.created,
400 submitter: ticket.submitter,
401 labels: ticket.labels,
402 assignees: ticket.assignees + [entity]
403 )
404 tickets[index] = updated
405 }
406
407 do {
408 _ = try await client.execute(
409 service: .todo,
410 query: Self.assignUserMutation,
411 variables: [
412 "trackerId": trackerId,
413 "ticketId": ticket.id,
414 "userId": user.id
415 ],
416 responseType: AssignmentMutationResponse.self
417 )
418 } catch {
419 tickets = original
420 self.error = error.userFacingMessage
421 }
422
423 isPerformingAction = false
424 }
425
426 func unassignFromMe(ticket: TicketSummary, user: User) async {
427 guard !isPerformingAction else { return }
428 isPerformingAction = true
429 error = nil
430
431 let original = tickets
432 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
433 let filtered = ticket.assignees.filter { assignee in
434 !Self.matchesAssignee(assignee, user: user)
435 }
436 let updated = TicketSummary(
437 id: ticket.id,
438 title: ticket.title,
439 status: ticket.status,
440 resolution: ticket.resolution,
441 created: ticket.created,
442 submitter: ticket.submitter,
443 labels: ticket.labels,
444 assignees: filtered
445 )
446 tickets[index] = updated
447 }
448
449 do {
450 _ = try await client.execute(
451 service: .todo,
452 query: Self.unassignUserMutation,
453 variables: [
454 "trackerId": trackerId,
455 "ticketId": ticket.id,
456 "userId": user.id
457 ],
458 responseType: AssignmentMutationResponse.self
459 )
460 } catch {
461 tickets = original
462 self.error = error.userFacingMessage
463 }
464
465 isPerformingAction = false
466 }
467
468 func loadTrackerLabels() async {
469 do {
470 let result = try await client.execute(
471 service: .todo,
472 query: Self.trackerLabelsQuery,
473 variables: ["rid": trackerRid],
474 responseType: TrackerLabelsResponse.self
475 )
476 syncTrackerLabels(result.tracker.labels.results)
477 } catch {
478 self.error = error.userFacingMessage
479 }
480 }
481
482 func syncTrackerLabels(_ labels: [TicketLabel]) {
483 trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
484 tickets = Self.synchronizeTickets(tickets, with: trackerLabels)
485 selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels)
486 }
487
488 func toggleLabelSelection(_ label: TicketLabel) {
489 if selectedLabelIDs.contains(label.id) {
490 selectedLabelIDs.remove(label.id)
491 } else {
492 selectedLabelIDs.insert(label.id)
493 }
494 }
495
496 func clearLabelSelection() {
497 selectedLabelIDs = []
498 }
499
500 func setSelectionMode(_ enabled: Bool) {
501 isSelectionMode = enabled
502 if !enabled {
503 clearTicketSelection()
504 }
505 }
506
507 func toggleTicketSelection(_ ticket: TicketSummary) {
508 if selectedTicketIDs.contains(ticket.id) {
509 selectedTicketIDs.remove(ticket.id)
510 } else {
511 selectedTicketIDs.insert(ticket.id)
512 }
513 }
514
515 func selectVisibleTickets(_ tickets: [TicketSummary]) {
516 selectedTicketIDs = Set(tickets.map(\.id))
517 }
518
519 func clearTicketSelection() {
520 selectedTicketIDs = []
521 }
522
523 func resetFilters() {
524 filter = .open
525 selectedLabelIDs = []
526 }
527
528 func recordRecentSearch(_ query: String) {
529 ScopedSearchHistoryStore.record(
530 query: query,
531 scopeID: Self.searchHistoryScopeID(for: trackerRid),
532 defaults: defaults
533 )
534 recentSearches = ScopedSearchHistoryStore.load(
535 scopeID: Self.searchHistoryScopeID(for: trackerRid),
536 defaults: defaults
537 )
538 }
539
540 func clearRecentSearches() {
541 ScopedSearchHistoryStore.clear(
542 scopeID: Self.searchHistoryScopeID(for: trackerRid),
543 defaults: defaults
544 )
545 recentSearches = []
546 }
547
548 func applySavedFilter(_ savedFilter: SavedTicketFilter) {
549 filter = savedFilter.state.status
550 selectedLabelIDs = Set(savedFilter.state.labelIDs)
551 activeSavedFilterID = savedFilter.id
552 }
553
554 func saveCurrentFilter(named name: String) {
555 guard let savedFilter = TicketSavedFilterStore.saveFilter(
556 named: name,
557 state: currentFilterState,
558 for: trackerRid,
559 defaults: defaults
560 ) else {
561 return
562 }
563
564 savedFilters.removeAll {
565 $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
566 }
567 savedFilters.insert(savedFilter, at: 0)
568 activeSavedFilterID = savedFilter.id
569 }
570
571 func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
572 TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
573 savedFilters.removeAll { $0.id == savedFilter.id }
574 if activeSavedFilterID == savedFilter.id {
575 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
576 }
577 }
578
579 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
580 guard !isPerformingAction else { return }
581 isPerformingAction = true
582 error = nil
583
584 let original = tickets
585 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
586 let updated = TicketSummary(
587 id: ticket.id,
588 title: ticket.title,
589 status: ticket.status,
590 resolution: ticket.resolution,
591 created: ticket.created,
592 submitter: ticket.submitter,
593 labels: ticket.labels + [label],
594 assignees: ticket.assignees
595 )
596 tickets[index] = updated
597 }
598
599 do {
600 _ = try await client.execute(
601 service: .todo,
602 query: Self.labelTicketMutation,
603 variables: [
604 "trackerId": trackerId,
605 "ticketId": ticket.id,
606 "labelId": label.id
607 ],
608 responseType: LabelMutationResponse.self
609 )
610 } catch {
611 tickets = original
612 self.error = error.userFacingMessage
613 }
614
615 isPerformingAction = false
616 }
617
618 func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
619 guard !isPerformingAction else { return }
620 isPerformingAction = true
621 error = nil
622
623 let original = tickets
624 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
625 let filtered = ticket.labels.filter { $0.id != label.id }
626 let updated = TicketSummary(
627 id: ticket.id,
628 title: ticket.title,
629 status: ticket.status,
630 resolution: ticket.resolution,
631 created: ticket.created,
632 submitter: ticket.submitter,
633 labels: filtered,
634 assignees: ticket.assignees
635 )
636 tickets[index] = updated
637 }
638
639 do {
640 _ = try await client.execute(
641 service: .todo,
642 query: Self.unlabelTicketMutation,
643 variables: [
644 "trackerId": trackerId,
645 "ticketId": ticket.id,
646 "labelId": label.id
647 ],
648 responseType: LabelMutationResponse.self
649 )
650 } catch {
651 tickets = original
652 self.error = error.userFacingMessage
653 }
654
655 isPerformingAction = false
656 }
657
658 func ticket(withId ticketId: Int) -> TicketSummary? {
659 tickets.first(where: { $0.id == ticketId })
660 }
661
662 func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? {
663 await performBulkAction(
664 kind: .close,
665 prepare: { ticket in
666 guard ticket.status != .resolved else { return .unchanged }
667
668 let input = Self.bulkStatusUpdateInput(resolution: resolution)
669 let updatedTicket = updatedTicket(from: ticket, input: input)
670 return .request(updatedTicket: updatedTicket) {
671 try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input)
672 }
673 }
674 )
675 }
676
677 func assignSelectedTickets(username: String) async -> TicketBulkActionResult? {
678 let normalizedUsername = Self.normalizedUsername(username)
679 guard !normalizedUsername.isEmpty else {
680 error = "Enter a SourceHut username."
681 return nil
682 }
683
684 do {
685 let userResult = try await client.execute(
686 service: .todo,
687 query: Self.userLookupQuery,
688 variables: ["username": normalizedUsername],
689 responseType: TicketListUserLookupResponse.self
690 )
691 guard let userID = userResult.user?.id else {
692 error = "That user couldn’t be found."
693 return nil
694 }
695
696 let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername))
697 return await performBulkAction(
698 kind: .assign,
699 prepare: { ticket in
700 guard !ticket.assignees.contains(where: {
701 Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName
702 }) else {
703 return .unchanged
704 }
705
706 let updatedTicket = TicketSummary(
707 id: ticket.id,
708 title: ticket.title,
709 status: ticket.status,
710 resolution: ticket.resolution,
711 created: ticket.created,
712 submitter: ticket.submitter,
713 labels: ticket.labels,
714 assignees: ticket.assignees + [assignee]
715 )
716
717 return .request(updatedTicket: updatedTicket) {
718 try await self.executeBulkAssign(ticketID: ticket.id, userID: userID)
719 }
720 }
721 )
722 } catch {
723 self.error = error.userFacingMessage
724 return nil
725 }
726 }
727
728 // MARK: - Private
729
730 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
731 guard !isPerformingAction else { return }
732 isPerformingAction = true
733 error = nil
734
735 do {
736 let variables: [String: any Sendable] = [
737 "trackerId": trackerId,
738 "ticketId": ticket.id,
739 "input": input
740 ]
741 let result = try await client.execute(
742 service: .todo,
743 query: Self.updateStatusMutation,
744 variables: variables,
745 responseType: UpdateStatusResponse.self
746 )
747 _ = result.updateTicketStatus
748 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
749 tickets[index] = updatedTicket(from: ticket, input: input)
750 }
751 } catch {
752 self.error = error.userFacingMessage
753 }
754
755 isPerformingAction = false
756 }
757
758 private func fetchPage(cursor: String?) async throws -> TicketsPage {
759 var variables: [String: any Sendable] = ["rid": trackerRid]
760 if let cursor {
761 variables["cursor"] = cursor
762 }
763 let result = try await client.execute(
764 service: .todo,
765 query: Self.query,
766 variables: variables,
767 responseType: TrackerTicketsResponse.self
768 )
769 return result.tracker.tickets
770 }
771
772 private struct SubmitTicketResponse: Decodable, Sendable {
773 let submitTicket: TicketSummary
774 }
775
776 private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
777 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
778 let userCanonical = normalizedCanonicalName(user.canonicalName)
779 if assigneeCanonical == userCanonical {
780 return true
781 }
782 return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
783 }
784
785 private static func normalizedCanonicalName(_ value: String) -> String {
786 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
787 if trimmed.hasPrefix("~") {
788 return trimmed
789 }
790 return "~\(trimmed)"
791 }
792
793 private static func normalizedUsername(_ value: String) -> String {
794 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
795 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
796 }
797
798 private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
799 let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
800 let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
801
802 return TicketSummary(
803 id: ticket.id,
804 title: ticket.title,
805 status: updatedStatus,
806 resolution: updatedStatus == .resolved ? updatedResolution : nil,
807 created: ticket.created,
808 submitter: ticket.submitter,
809 labels: ticket.labels,
810 assignees: ticket.assignees
811 )
812 }
813
814 private func persistFilterState() {
815 TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
816 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
817 }
818
819 private func reconcileSelectionWithLoadedTickets() {
820 let loadedTicketIDs = Set(tickets.map(\.id))
821 selectedTicketIDs.formIntersection(loadedTicketIDs)
822 if isSelectionMode, selectedTicketIDs.isEmpty {
823 isSelectionMode = false
824 }
825 }
826
827 private func performBulkAction(
828 kind: TicketBulkActionKind,
829 prepare: (TicketSummary) -> TicketBulkTicketOperation
830 ) async -> TicketBulkActionResult? {
831 guard !isPerformingAction else { return nil }
832
833 let selected = selectedTickets
834 guard !selected.isEmpty else { return nil }
835
836 isPerformingAction = true
837 error = nil
838
839 var updatedCount = 0
840 var unchangedCount = 0
841 var failures: [TicketBulkActionFailure] = []
842 var failedTicketIDs = Set<Int>()
843
844 for ticket in selected {
845 switch prepare(ticket) {
846 case .unchanged:
847 unchangedCount += 1
848 case .request(let updatedTicket, let request):
849 replaceTicket(updatedTicket)
850 do {
851 try await request()
852 updatedCount += 1
853 } catch {
854 replaceTicket(ticket)
855 failedTicketIDs.insert(ticket.id)
856 failures.append(
857 TicketBulkActionFailure(
858 ticketID: ticket.id,
859 message: error.userFacingMessage
860 )
861 )
862 }
863 }
864 }
865
866 isPerformingAction = false
867
868 let result = TicketBulkActionResult(
869 action: kind,
870 totalCount: selected.count,
871 updatedCount: updatedCount,
872 unchangedCount: unchangedCount,
873 failures: failures
874 )
875
876 if failedTicketIDs.isEmpty {
877 clearTicketSelection()
878 isSelectionMode = false
879 } else {
880 selectedTicketIDs = failedTicketIDs
881 isSelectionMode = true
882 }
883
884 return result
885 }
886
887 private func replaceTicket(_ ticket: TicketSummary) {
888 guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return }
889 tickets[index] = ticket
890 }
891
892 private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws {
893 _ = try await client.execute(
894 service: .todo,
895 query: Self.updateStatusMutation,
896 variables: [
897 "trackerId": trackerId,
898 "ticketId": ticketID,
899 "input": input
900 ],
901 responseType: UpdateStatusResponse.self
902 )
903 }
904
905 private func executeBulkAssign(ticketID: Int, userID: Int) async throws {
906 _ = try await client.execute(
907 service: .todo,
908 query: Self.assignUserMutation,
909 variables: [
910 "trackerId": trackerId,
911 "ticketId": ticketID,
912 "userId": userID
913 ],
914 responseType: AssignmentMutationResponse.self
915 )
916 }
917
918 private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
919 [
920 "status": TicketStatus.resolved.rawValue,
921 "resolution": resolution.rawValue
922 ]
923 }
924
925 static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] {
926 let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) })
927
928 return tickets.map { ticket in
929 let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] }
930 return TicketSummary(
931 id: ticket.id,
932 title: ticket.title,
933 status: ticket.status,
934 resolution: ticket.resolution,
935 created: ticket.created,
936 submitter: ticket.submitter,
937 labels: updatedLabels,
938 assignees: ticket.assignees
939 )
940 }
941 }
942
943 static func reconciledSelectedLabelIDs(
944 _ selectedLabelIDs: Set<Int>,
945 availableLabels: [TicketLabel]
946 ) -> Set<Int> {
947 selectedLabelIDs.intersection(Set(availableLabels.map(\.id)))
948 }
949
950 static func filterTickets(
951 _ tickets: [TicketSummary],
952 state: TicketListFilterState,
953 query: String
954 ) -> [TicketSummary] {
955 let statusFiltered: [TicketSummary]
956 switch state.status {
957 case .open:
958 statusFiltered = tickets.filter { $0.status.isOpen }
959 case .resolved:
960 statusFiltered = tickets.filter { !$0.status.isOpen }
961 case .all:
962 statusFiltered = tickets
963 }
964
965 let labelFiltered: [TicketSummary]
966 if state.labelIDs.isEmpty {
967 labelFiltered = statusFiltered
968 } else {
969 let selectedLabelIDs = Set(state.labelIDs)
970 labelFiltered = statusFiltered.filter { ticket in
971 !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
972 }
973 }
974
975 let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
976 guard !q.isEmpty else { return labelFiltered }
977 return labelFiltered.filter {
978 String($0.id).contains(q) ||
979 $0.title.lowercased().contains(q) ||
980 $0.submitter.canonicalName.lowercased().contains(q) ||
981 $0.labels.contains { $0.name.lowercased().contains(q) } ||
982 $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
983 $0.status.displayName.lowercased().contains(q)
984 }
985 }
986}
987
988private enum TicketBulkTicketOperation {
989 case unchanged
990 case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void)
991}