krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.0.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 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    private(set) var isLoading = false
 88    private(set) var isLoadingMore = false
 89    private(set) var isCreatingTicket = false
 90    private(set) var isPerformingAction = false
 91    private(set) var trackerLabels: [TicketLabel] = []
 92    private(set) var recentSearches: [ScopedSearchHistoryEntry]
 93    private(set) var savedFilters: [SavedTicketFilter]
 94    private(set) var isSelectionMode = false
 95    private(set) var selectedTicketIDs: Set<Int> = []
 96    var error: String?
 97    var filter: TicketFilter = .open {
 98        didSet {
 99            persistFilterState()
100        }
101    }
102    var selectedLabelIDs: Set<Int> = [] {
103        didSet {
104            persistFilterState()
105        }
106    }
107    var searchText = ""
108    private(set) var activeSavedFilterID: SavedTicketFilter.ID?
109
110    private var cursor: String?
111    private var hasMore = true
112    private let client: SRHTClient
113    private let defaults: UserDefaults
114
115    init(
116        ownerUsername: String,
117        trackerName: String,
118        trackerId: Int,
119        trackerRid: String,
120        client: SRHTClient,
121        defaults: UserDefaults = .standard
122    ) {
123        self.ownerUsername = ownerUsername
124        self.trackerName = trackerName
125        self.trackerId = trackerId
126        self.trackerRid = trackerRid
127        self.client = client
128        self.defaults = defaults
129
130        let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
131        let savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults)
132        self.filter = restoredState.status
133        self.selectedLabelIDs = Set(restoredState.labelIDs)
134        self.savedFilters = savedFilters
135        self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id
136        self.recentSearches = ScopedSearchHistoryStore.load(
137            scopeID: Self.searchHistoryScopeID(for: trackerRid),
138            defaults: defaults
139        )
140    }
141
142    // MARK: - Query
143
144    private static let query = """
145    query tickets($rid: ID!, $cursor: Cursor) {
146        tracker(rid: $rid) {
147            tickets(cursor: $cursor) {
148                results {
149                    id
150                    title: subject
151                    status
152                    resolution
153                    created
154                    submitter { canonicalName }
155                    labels { id name backgroundColor foregroundColor }
156                    assignees { canonicalName }
157                }
158                cursor
159            }
160        }
161    }
162    """
163
164    private static let submitTicketMutation = """
165    mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
166        submitTicket(trackerId: $trackerId, input: $input) {
167            id
168            title: subject
169            status
170            resolution
171            created
172            submitter { canonicalName }
173            labels { id name backgroundColor foregroundColor }
174            assignees { canonicalName }
175        }
176    }
177    """
178
179    private static let updateStatusMutation = """
180    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
181        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
182            eventType: __typename
183        }
184    }
185    """
186
187    private static let assignUserMutation = """
188    mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
189        assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
190    }
191    """
192
193    private static let unassignUserMutation = """
194    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
195        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
196    }
197    """
198
199    private static let labelTicketMutation = """
200    mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
201        labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
202    }
203    """
204
205    private static let unlabelTicketMutation = """
206    mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
207        unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
208    }
209    """
210
211    private static let trackerLabelsQuery = """
212    query trackerLabels($rid: ID!) {
213        tracker(rid: $rid) {
214            labels {
215                results { id name backgroundColor foregroundColor }
216            }
217        }
218    }
219    """
220
221    private static let userLookupQuery = """
222    query userLookup($username: String!) {
223        user(username: $username) { id }
224    }
225    """
226
227    // MARK: - Computed
228
229    var currentFilterState: TicketListFilterState {
230        TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
231    }
232
233    var availableLabels: [TicketLabel] {
234        let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
235        let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
236            partialResult[label.id] = label
237        }
238        return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
239    }
240
241    var selectedLabels: [TicketLabel] {
242        availableLabels.filter { selectedLabelIDs.contains($0.id) }
243    }
244
245    var selectedTickets: [TicketSummary] {
246        tickets.filter { selectedTicketIDs.contains($0.id) }
247    }
248
249    var selectedTicketCount: Int {
250        selectedTicketIDs.count
251    }
252
253    var suggestedSavedFilterName: String {
254        let labelNames = selectedLabels.map(\.name).sorted()
255        var components: [String] = []
256
257        if filter != .open || !labelNames.isEmpty {
258            components.append(filter.rawValue)
259        }
260        if !labelNames.isEmpty {
261            components.append(labelNames.joined(separator: ", "))
262        }
263
264        return components.isEmpty ? "Open Tickets" : components.joined(separator: "")
265    }
266
267    var hasCustomFilterSelection: Bool {
268        !currentFilterState.isDefault
269    }
270
271    /// Tickets filtered by the selected status and label filters.
272    var filteredTickets: [TicketSummary] {
273        Self.filterTickets(tickets, state: currentFilterState, query: searchText)
274    }
275
276    // MARK: - Public API
277
278    func loadTickets() async {
279        isLoading = true
280        error = nil
281        cursor = nil
282        hasMore = true
283
284        do {
285            let page = try await fetchPage(cursor: nil)
286            tickets = page.results
287            cursor = page.cursor
288            hasMore = page.cursor != nil
289            reconcileSelectionWithLoadedTickets()
290        } catch {
291            self.error = error.userFacingMessage
292        }
293
294        isLoading = false
295    }
296
297    func loadMoreIfNeeded(currentItem: TicketSummary) async {
298        guard let last = tickets.last,
299              last.id == currentItem.id,
300              hasMore,
301              !isLoadingMore else {
302            return
303        }
304
305        isLoadingMore = true
306
307        do {
308            let page = try await fetchPage(cursor: cursor)
309            tickets.append(contentsOf: page.results)
310            cursor = page.cursor
311            hasMore = page.cursor != nil
312            reconcileSelectionWithLoadedTickets()
313        } catch {
314            self.error = error.userFacingMessage
315        }
316
317        isLoadingMore = false
318    }
319
320    func createTicket(subject: String, body: String) async -> TicketSummary? {
321        guard !isCreatingTicket else { return nil }
322
323        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
324        guard !trimmedSubject.isEmpty else {
325            error = "Enter a ticket title."
326            return nil
327        }
328
329        isCreatingTicket = true
330        error = nil
331        defer { isCreatingTicket = false }
332
333        var input: [String: any Sendable] = [
334            "subject": trimmedSubject
335        ]
336        let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
337        if !trimmedBody.isEmpty {
338            input["body"] = trimmedBody
339        }
340        let variables: [String: any Sendable] = [
341            "trackerId": trackerId,
342            "input": input
343        ]
344
345        do {
346            let result = try await client.execute(
347                service: .todo,
348                query: Self.submitTicketMutation,
349                variables: variables,
350                responseType: SubmitTicketResponse.self
351            )
352            let ticket = result.submitTicket
353            tickets.insert(ticket, at: 0)
354            return ticket
355        } catch {
356            self.error = "Couldn’t create the ticket. \(error.userFacingMessage)"
357            return nil
358        }
359    }
360
361    func resolveTicket(_ ticket: TicketSummary) async {
362        let input: [String: any Sendable] = [
363            "status": TicketStatus.resolved.rawValue,
364            "resolution": TicketResolution.fixed.rawValue
365        ]
366        await performStatusUpdate(ticket: ticket, input: input)
367    }
368
369    func reopenTicket(_ ticket: TicketSummary) async {
370        let input: [String: any Sendable] = [
371            "status": TicketStatus.reported.rawValue
372        ]
373        await performStatusUpdate(ticket: ticket, input: input)
374    }
375
376    func assignToMe(ticket: TicketSummary, user: User) async {
377        guard !isPerformingAction else { return }
378        isPerformingAction = true
379        error = nil
380
381        let original = tickets
382        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
383            let entity = Entity(canonicalName: user.canonicalName)
384            let updated = TicketSummary(
385                id: ticket.id,
386                title: ticket.title,
387                status: ticket.status,
388                resolution: ticket.resolution,
389                created: ticket.created,
390                submitter: ticket.submitter,
391                labels: ticket.labels,
392                assignees: ticket.assignees + [entity]
393            )
394            tickets[index] = updated
395        }
396
397        do {
398            _ = try await client.execute(
399                service: .todo,
400                query: Self.assignUserMutation,
401                variables: [
402                    "trackerId": trackerId,
403                    "ticketId": ticket.id,
404                    "userId": user.id
405                ],
406                responseType: AssignmentMutationResponse.self
407            )
408        } catch {
409            tickets = original
410            self.error = error.userFacingMessage
411        }
412
413        isPerformingAction = false
414    }
415
416    func unassignFromMe(ticket: TicketSummary, user: User) async {
417        guard !isPerformingAction else { return }
418        isPerformingAction = true
419        error = nil
420
421        let original = tickets
422        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
423            let filtered = ticket.assignees.filter { assignee in
424                !Self.matchesAssignee(assignee, user: user)
425            }
426            let updated = TicketSummary(
427                id: ticket.id,
428                title: ticket.title,
429                status: ticket.status,
430                resolution: ticket.resolution,
431                created: ticket.created,
432                submitter: ticket.submitter,
433                labels: ticket.labels,
434                assignees: filtered
435            )
436            tickets[index] = updated
437        }
438
439        do {
440            _ = try await client.execute(
441                service: .todo,
442                query: Self.unassignUserMutation,
443                variables: [
444                    "trackerId": trackerId,
445                    "ticketId": ticket.id,
446                    "userId": user.id
447                ],
448                responseType: AssignmentMutationResponse.self
449            )
450        } catch {
451            tickets = original
452            self.error = error.userFacingMessage
453        }
454
455        isPerformingAction = false
456    }
457
458    func loadTrackerLabels() async {
459        do {
460            let result = try await client.execute(
461                service: .todo,
462                query: Self.trackerLabelsQuery,
463                variables: ["rid": trackerRid],
464                responseType: TrackerLabelsResponse.self
465            )
466            syncTrackerLabels(result.tracker.labels.results)
467        } catch {
468            self.error = error.userFacingMessage
469        }
470    }
471
472    func syncTrackerLabels(_ labels: [TicketLabel]) {
473        trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
474        tickets = Self.synchronizeTickets(tickets, with: trackerLabels)
475        selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels)
476    }
477
478    func toggleLabelSelection(_ label: TicketLabel) {
479        if selectedLabelIDs.contains(label.id) {
480            selectedLabelIDs.remove(label.id)
481        } else {
482            selectedLabelIDs.insert(label.id)
483        }
484    }
485
486    func clearLabelSelection() {
487        selectedLabelIDs = []
488    }
489
490    func setSelectionMode(_ enabled: Bool) {
491        isSelectionMode = enabled
492        if !enabled {
493            clearTicketSelection()
494        }
495    }
496
497    func toggleTicketSelection(_ ticket: TicketSummary) {
498        if selectedTicketIDs.contains(ticket.id) {
499            selectedTicketIDs.remove(ticket.id)
500        } else {
501            selectedTicketIDs.insert(ticket.id)
502        }
503    }
504
505    func selectVisibleTickets(_ tickets: [TicketSummary]) {
506        selectedTicketIDs = Set(tickets.map(\.id))
507    }
508
509    func clearTicketSelection() {
510        selectedTicketIDs = []
511    }
512
513    func resetFilters() {
514        filter = .open
515        selectedLabelIDs = []
516    }
517
518    func recordRecentSearch(_ query: String) {
519        ScopedSearchHistoryStore.record(
520            query: query,
521            scopeID: Self.searchHistoryScopeID(for: trackerRid),
522            defaults: defaults
523        )
524        recentSearches = ScopedSearchHistoryStore.load(
525            scopeID: Self.searchHistoryScopeID(for: trackerRid),
526            defaults: defaults
527        )
528    }
529
530    func clearRecentSearches() {
531        ScopedSearchHistoryStore.clear(
532            scopeID: Self.searchHistoryScopeID(for: trackerRid),
533            defaults: defaults
534        )
535        recentSearches = []
536    }
537
538    func applySavedFilter(_ savedFilter: SavedTicketFilter) {
539        filter = savedFilter.state.status
540        selectedLabelIDs = Set(savedFilter.state.labelIDs)
541        activeSavedFilterID = savedFilter.id
542    }
543
544    func saveCurrentFilter(named name: String) {
545        guard let savedFilter = TicketSavedFilterStore.saveFilter(
546            named: name,
547            state: currentFilterState,
548            for: trackerRid,
549            defaults: defaults
550        ) else {
551            return
552        }
553
554        savedFilters.removeAll {
555            $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
556        }
557        savedFilters.insert(savedFilter, at: 0)
558        activeSavedFilterID = savedFilter.id
559    }
560
561    func deleteSavedFilter(_ savedFilter: SavedTicketFilter) {
562        TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults)
563        savedFilters.removeAll { $0.id == savedFilter.id }
564        if activeSavedFilterID == savedFilter.id {
565            activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
566        }
567    }
568
569    func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
570        guard !isPerformingAction else { return }
571        isPerformingAction = true
572        error = nil
573
574        let original = tickets
575        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
576            let updated = TicketSummary(
577                id: ticket.id,
578                title: ticket.title,
579                status: ticket.status,
580                resolution: ticket.resolution,
581                created: ticket.created,
582                submitter: ticket.submitter,
583                labels: ticket.labels + [label],
584                assignees: ticket.assignees
585            )
586            tickets[index] = updated
587        }
588
589        do {
590            _ = try await client.execute(
591                service: .todo,
592                query: Self.labelTicketMutation,
593                variables: [
594                    "trackerId": trackerId,
595                    "ticketId": ticket.id,
596                    "labelId": label.id
597                ],
598                responseType: LabelMutationResponse.self
599            )
600        } catch {
601            tickets = original
602            self.error = error.userFacingMessage
603        }
604
605        isPerformingAction = false
606    }
607
608    func unlabelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
609        guard !isPerformingAction else { return }
610        isPerformingAction = true
611        error = nil
612
613        let original = tickets
614        if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
615            let filtered = ticket.labels.filter { $0.id != label.id }
616            let updated = TicketSummary(
617                id: ticket.id,
618                title: ticket.title,
619                status: ticket.status,
620                resolution: ticket.resolution,
621                created: ticket.created,
622                submitter: ticket.submitter,
623                labels: filtered,
624                assignees: ticket.assignees
625            )
626            tickets[index] = updated
627        }
628
629        do {
630            _ = try await client.execute(
631                service: .todo,
632                query: Self.unlabelTicketMutation,
633                variables: [
634                    "trackerId": trackerId,
635                    "ticketId": ticket.id,
636                    "labelId": label.id
637                ],
638                responseType: LabelMutationResponse.self
639            )
640        } catch {
641            tickets = original
642            self.error = error.userFacingMessage
643        }
644
645        isPerformingAction = false
646    }
647
648    func ticket(withId ticketId: Int) -> TicketSummary? {
649        tickets.first(where: { $0.id == ticketId })
650    }
651
652    func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? {
653        await performBulkAction(
654            kind: .close,
655            prepare: { ticket in
656                guard ticket.status != .resolved else { return .unchanged }
657
658                let input = Self.bulkStatusUpdateInput(resolution: resolution)
659                let updatedTicket = updatedTicket(from: ticket, input: input)
660                return .request(updatedTicket: updatedTicket) {
661                    try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input)
662                }
663            }
664        )
665    }
666
667    func assignSelectedTickets(username: String) async -> TicketBulkActionResult? {
668        let normalizedUsername = Self.normalizedUsername(username)
669        guard !normalizedUsername.isEmpty else {
670            error = "Enter a SourceHut username."
671            return nil
672        }
673
674        do {
675            let userResult = try await client.execute(
676                service: .todo,
677                query: Self.userLookupQuery,
678                variables: ["username": normalizedUsername],
679                responseType: TicketListUserLookupResponse.self
680            )
681            guard let userID = userResult.user?.id else {
682                error = "That user couldn’t be found."
683                return nil
684            }
685
686            let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername))
687            return await performBulkAction(
688                kind: .assign,
689                prepare: { ticket in
690                    guard !ticket.assignees.contains(where: {
691                        Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName
692                    }) else {
693                        return .unchanged
694                    }
695
696                    let updatedTicket = TicketSummary(
697                        id: ticket.id,
698                        title: ticket.title,
699                        status: ticket.status,
700                        resolution: ticket.resolution,
701                        created: ticket.created,
702                        submitter: ticket.submitter,
703                        labels: ticket.labels,
704                        assignees: ticket.assignees + [assignee]
705                    )
706
707                    return .request(updatedTicket: updatedTicket) {
708                        try await self.executeBulkAssign(ticketID: ticket.id, userID: userID)
709                    }
710                }
711            )
712        } catch {
713            self.error = error.userFacingMessage
714            return nil
715        }
716    }
717
718    // MARK: - Private
719
720    private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
721        guard !isPerformingAction else { return }
722        isPerformingAction = true
723        error = nil
724
725        do {
726            let variables: [String: any Sendable] = [
727                "trackerId": trackerId,
728                "ticketId": ticket.id,
729                "input": input
730            ]
731            let result = try await client.execute(
732                service: .todo,
733                query: Self.updateStatusMutation,
734                variables: variables,
735                responseType: UpdateStatusResponse.self
736            )
737            _ = result.updateTicketStatus
738            if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
739                tickets[index] = updatedTicket(from: ticket, input: input)
740            }
741        } catch {
742            self.error = error.userFacingMessage
743        }
744
745        isPerformingAction = false
746    }
747
748    private func fetchPage(cursor: String?) async throws -> TicketsPage {
749        var variables: [String: any Sendable] = ["rid": trackerRid]
750        if let cursor {
751            variables["cursor"] = cursor
752        }
753        let result = try await client.execute(
754            service: .todo,
755            query: Self.query,
756            variables: variables,
757            responseType: TrackerTicketsResponse.self
758        )
759        return result.tracker.tickets
760    }
761
762    private struct SubmitTicketResponse: Decodable, Sendable {
763        let submitTicket: TicketSummary
764    }
765
766    private static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
767        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
768        let userCanonical = normalizedCanonicalName(user.canonicalName)
769        if assigneeCanonical == userCanonical {
770            return true
771        }
772        return normalizedUsername(entity.canonicalName) == normalizedUsername(user.username)
773    }
774
775    private static func normalizedCanonicalName(_ value: String) -> String {
776        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
777        if trimmed.hasPrefix("~") {
778            return trimmed
779        }
780        return "~\(trimmed)"
781    }
782
783    private static func normalizedUsername(_ value: String) -> String {
784        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
785        return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
786    }
787
788    private func updatedTicket(from ticket: TicketSummary, input: [String: any Sendable]) -> TicketSummary {
789        let updatedStatus = (input["status"] as? String).flatMap(TicketStatus.init(rawValue:)) ?? ticket.status
790        let updatedResolution = (input["resolution"] as? String).flatMap(TicketResolution.init(rawValue:))
791
792        return TicketSummary(
793            id: ticket.id,
794            title: ticket.title,
795            status: updatedStatus,
796            resolution: updatedStatus == .resolved ? updatedResolution : nil,
797            created: ticket.created,
798            submitter: ticket.submitter,
799            labels: ticket.labels,
800            assignees: ticket.assignees
801        )
802    }
803
804    private func persistFilterState() {
805        TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults)
806        activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
807    }
808
809    private func reconcileSelectionWithLoadedTickets() {
810        let loadedTicketIDs = Set(tickets.map(\.id))
811        selectedTicketIDs.formIntersection(loadedTicketIDs)
812        if isSelectionMode, selectedTicketIDs.isEmpty {
813            isSelectionMode = false
814        }
815    }
816
817    private func performBulkAction(
818        kind: TicketBulkActionKind,
819        prepare: (TicketSummary) -> TicketBulkTicketOperation
820    ) async -> TicketBulkActionResult? {
821        guard !isPerformingAction else { return nil }
822
823        let selected = selectedTickets
824        guard !selected.isEmpty else { return nil }
825
826        isPerformingAction = true
827        error = nil
828
829        var updatedCount = 0
830        var unchangedCount = 0
831        var failures: [TicketBulkActionFailure] = []
832        var failedTicketIDs = Set<Int>()
833
834        for ticket in selected {
835            switch prepare(ticket) {
836            case .unchanged:
837                unchangedCount += 1
838            case .request(let updatedTicket, let request):
839                replaceTicket(updatedTicket)
840                do {
841                    try await request()
842                    updatedCount += 1
843                } catch {
844                    replaceTicket(ticket)
845                    failedTicketIDs.insert(ticket.id)
846                    failures.append(
847                        TicketBulkActionFailure(
848                            ticketID: ticket.id,
849                            message: error.userFacingMessage
850                        )
851                    )
852                }
853            }
854        }
855
856        isPerformingAction = false
857
858        let result = TicketBulkActionResult(
859            action: kind,
860            totalCount: selected.count,
861            updatedCount: updatedCount,
862            unchangedCount: unchangedCount,
863            failures: failures
864        )
865
866        if failedTicketIDs.isEmpty {
867            clearTicketSelection()
868            isSelectionMode = false
869        } else {
870            selectedTicketIDs = failedTicketIDs
871            isSelectionMode = true
872        }
873
874        return result
875    }
876
877    private func replaceTicket(_ ticket: TicketSummary) {
878        guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return }
879        tickets[index] = ticket
880    }
881
882    private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws {
883        _ = try await client.execute(
884            service: .todo,
885            query: Self.updateStatusMutation,
886            variables: [
887                "trackerId": trackerId,
888                "ticketId": ticketID,
889                "input": input
890            ],
891            responseType: UpdateStatusResponse.self
892        )
893    }
894
895    private func executeBulkAssign(ticketID: Int, userID: Int) async throws {
896        _ = try await client.execute(
897            service: .todo,
898            query: Self.assignUserMutation,
899            variables: [
900                "trackerId": trackerId,
901                "ticketId": ticketID,
902                "userId": userID
903            ],
904            responseType: AssignmentMutationResponse.self
905        )
906    }
907
908    private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
909        [
910            "status": TicketStatus.resolved.rawValue,
911            "resolution": resolution.rawValue
912        ]
913    }
914
915    static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] {
916        let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) })
917
918        return tickets.map { ticket in
919            let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] }
920            return TicketSummary(
921                id: ticket.id,
922                title: ticket.title,
923                status: ticket.status,
924                resolution: ticket.resolution,
925                created: ticket.created,
926                submitter: ticket.submitter,
927                labels: updatedLabels,
928                assignees: ticket.assignees
929            )
930        }
931    }
932
933    static func reconciledSelectedLabelIDs(
934        _ selectedLabelIDs: Set<Int>,
935        availableLabels: [TicketLabel]
936    ) -> Set<Int> {
937        selectedLabelIDs.intersection(Set(availableLabels.map(\.id)))
938    }
939
940    static func filterTickets(
941        _ tickets: [TicketSummary],
942        state: TicketListFilterState,
943        query: String
944    ) -> [TicketSummary] {
945        let statusFiltered: [TicketSummary]
946        switch state.status {
947        case .open:
948            statusFiltered = tickets.filter { $0.status.isOpen }
949        case .resolved:
950            statusFiltered = tickets.filter { !$0.status.isOpen }
951        case .all:
952            statusFiltered = tickets
953        }
954
955        let labelFiltered: [TicketSummary]
956        if state.labelIDs.isEmpty {
957            labelFiltered = statusFiltered
958        } else {
959            let selectedLabelIDs = Set(state.labelIDs)
960            labelFiltered = statusFiltered.filter { ticket in
961                !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id))
962            }
963        }
964
965        let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
966        guard !q.isEmpty else { return labelFiltered }
967        return labelFiltered.filter {
968            String($0.id).contains(q) ||
969            $0.title.lowercased().contains(q) ||
970            $0.submitter.canonicalName.lowercased().contains(q) ||
971            $0.labels.contains { $0.name.lowercased().contains(q) } ||
972            $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
973            $0.status.displayName.lowercased().contains(q)
974        }
975    }
976}
977
978private enum TicketBulkTicketOperation {
979    case unchanged
980    case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void)
981}