krz/hutch

an ios client for sourcehut

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

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

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