krz/hutch

an ios client for sourcehut

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

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