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