krz/hutch

an ios client for sourcehut

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

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

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