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