krz/hutch

an ios client for sourcehut

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

v3.0.4: Hutch/Views/Tickets/TicketListViewModel.swift · raw

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