krz/hutch

an ios client for sourcehut

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

v2.14.0: Hutch/Views/Home/HomeViewModel.swift · raw

   1import Foundation
   2import os
   3
   4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
   5
   6private struct HomeJobsResponse: Decodable, Sendable {
   7    let jobs: HomeJobsPage
   8}
   9
  10private struct HomeJobsPage: Decodable, Sendable {
  11    let results: [HomeJobPayload]
  12}
  13
  14private struct HomeTrackersResponse: Decodable, Sendable {
  15    let trackers: HomeTrackersPage
  16}
  17
  18private struct HomeTrackersPage: Decodable, Sendable {
  19    let results: [TrackerSummary]
  20    let cursor: String?
  21}
  22
  23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
  24    let user: HomeTrackerTicketsUser
  25}
  26
  27private struct HomeTrackerTicketsUser: Decodable, Sendable {
  28    let tracker: HomeTrackerTicketsTracker
  29}
  30
  31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
  32    let tickets: HomeTrackerTicketsPage
  33}
  34
  35private struct HomeTrackerTicketsPage: Decodable, Sendable {
  36    let results: [HomeTicketPayload]
  37}
  38
  39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
  40    let subscriptions: HomeInboxSubscriptionPage
  41}
  42
  43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
  44    let results: [HomeInboxSubscription]
  45    let cursor: String?
  46}
  47
  48private struct HomeInboxSubscription: Decodable, Sendable {
  49    let list: InboxMailingListReference?
  50}
  51
  52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
  53    let list: HomeInboxMailingListThreads
  54}
  55
  56private struct HomeInboxMailingListThreads: Decodable, Sendable {
  57    let threads: HomeInboxThreadPage
  58}
  59
  60private struct HomeInboxThreadPage: Decodable, Sendable {
  61    let results: [HomeInboxThreadPayload]
  62    let cursor: String?
  63}
  64
  65private struct HomeInboxThreadPayload: Decodable, Sendable {
  66    let created: Date
  67    let updated: Date
  68    let subject: String
  69    let replies: Int
  70    let sender: Entity
  71    let root: HomeInboxEmailPreview
  72}
  73
  74private struct HomeInboxEmailPreview: Decodable, Sendable {
  75    let id: Int
  76    let subject: String
  77    let date: Date?
  78    let received: Date
  79    let messageID: String
  80    let body: String
  81    let patch: HomeInboxPatchPreview?
  82}
  83
  84private struct HomeInboxPatchPreview: Decodable, Sendable {
  85    let subject: String?
  86}
  87
  88private struct HomeInboxUnreadSnapshot: Sendable {
  89    let unreadCount: Int
  90    let threads: [InboxThreadSummary]
  91}
  92
  93private struct HomeTicketPayload: Decodable, Sendable {
  94    let id: Int
  95    let title: String
  96    let status: TicketStatus
  97    let resolution: TicketResolution?
  98    let created: Date
  99    let submitter: Entity
 100    let labels: [TicketLabel]
 101    let assignees: [Entity]
 102
 103    enum CodingKeys: String, CodingKey {
 104        case id
 105        case title = "subject"
 106        case status
 107        case resolution
 108        case created
 109        case submitter
 110        case labels
 111        case assignees
 112    }
 113
 114    var ticketSummary: TicketSummary {
 115        TicketSummary(
 116            id: id,
 117            title: title,
 118            status: status,
 119            resolution: resolution,
 120            created: created,
 121            submitter: submitter,
 122            labels: labels,
 123            assignees: assignees
 124        )
 125    }
 126}
 127
 128struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
 129    let trackerId: Int
 130    let trackerRid: String
 131    let trackerName: String
 132    let ownerCanonicalName: String
 133    let ticket: TicketSummary
 134
 135    var id: String {
 136        "\(trackerRid)#\(ticket.id)"
 137    }
 138
 139    var ownerUsername: String {
 140        if ownerCanonicalName.hasPrefix("~") {
 141            return String(ownerCanonicalName.dropFirst())
 142        }
 143        return ownerCanonicalName
 144    }
 145}
 146
 147struct HomeBuildItem: Identifiable, Hashable, Sendable {
 148    let job: JobSummary
 149    let repositoryName: String?
 150    let repositoryOwner: String?
 151
 152    var id: Int { job.id }
 153
 154    var repositoryDisplayName: String? {
 155        guard let repositoryName else { return nil }
 156        if let repositoryOwner {
 157            return "\(repositoryOwner)/\(repositoryName)"
 158        }
 159        return repositoryName
 160    }
 161
 162    var requiresAttention: Bool {
 163        switch job.status {
 164        case .failed, .timeout, .running, .queued, .pending:
 165            true
 166        case .success, .cancelled:
 167            false
 168        }
 169    }
 170}
 171
 172@Observable
 173@MainActor
 174final class HomeViewModel {
 175    private(set) var projects: [Project] = []
 176    var assignedTickets: [HomeAssignedTicket] = []
 177    var recentBuilds: [HomeBuildItem] = []
 178    var unreadInboxThreads: [InboxThreadSummary] = []
 179    private(set) var systemStatusSnapshot: SystemStatusSnapshot?
 180    private(set) var isLoadingSystemStatus = false
 181    private(set) var isShowingStaleSystemStatus = false
 182    private(set) var systemStatusErrorMessage: String?
 183    private(set) var hasUnreadInboxThreads = false
 184    private(set) var unreadInboxThreadCount: Int?
 185    private(set) var isLoadingProjects = false
 186    private(set) var isLoadingAssignedTickets = false
 187    private(set) var isLoadingRecentBuilds = false
 188    private(set) var assignedTicketsError: String?
 189    private(set) var recentBuildsError: String?
 190
 191    private let currentUser: User
 192    private let client: SRHTClient
 193    private let systemStatusRepository: SystemStatusRepository
 194    private let projectService: ProjectService
 195    private let ticketFetchConcurrencyLimit = 6
 196    private let inboxUnreadConcurrencyLimit = 4
 197
 198    private static let jobsQuery = """
 199    query jobs {
 200        jobs {
 201            results {
 202                id
 203                created
 204                updated
 205                status
 206                note
 207                tags
 208                visibility
 209                image
 210                tasks { name status }
 211                manifest
 212            }
 213        }
 214    }
 215    """
 216
 217    private static let trackersQuery = """
 218    query trackers($cursor: Cursor) {
 219        trackers(cursor: $cursor) {
 220            results {
 221                id
 222                rid
 223                name
 224                description
 225                visibility
 226                updated
 227                owner { canonicalName }
 228            }
 229            cursor
 230        }
 231    }
 232    """
 233
 234    private static let trackerTicketsQuery = """
 235    query tickets($owner: String!, $tracker: String!) {
 236        user(username: $owner) {
 237            tracker(name: $tracker) {
 238                tickets {
 239                    results {
 240                        id
 241                        subject
 242                        status
 243                        resolution
 244                        created
 245                        submitter { canonicalName }
 246                        labels { id name backgroundColor foregroundColor }
 247                        assignees { canonicalName }
 248                    }
 249                }
 250            }
 251        }
 252    }
 253    """
 254
 255    private static let inboxSubscriptionsQuery = """
 256    query inboxSubscriptions($cursor: Cursor) {
 257        subscriptions(cursor: $cursor) {
 258            results {
 259                ... on MailingListSubscription {
 260                    list {
 261                        id
 262                        rid
 263                        name
 264                        owner { canonicalName }
 265                    }
 266                }
 267            }
 268            cursor
 269        }
 270    }
 271    """
 272
 273    private static let inboxListThreadsQuery = """
 274    query inboxListThreads($rid: ID!, $cursor: Cursor) {
 275        list(rid: $rid) {
 276            threads(cursor: $cursor) {
 277                results {
 278                    created
 279                    updated
 280                    subject
 281                    replies
 282                    sender { canonicalName }
 283                    root {
 284                        id
 285                        subject
 286                        date
 287                        received
 288                        messageID
 289                        body
 290                        patch { subject }
 291                    }
 292                }
 293                cursor
 294            }
 295        }
 296    }
 297    """
 298
 299    private static let updateTicketStatusMutation = """
 300    mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
 301        updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
 302            eventType: __typename
 303        }
 304    }
 305    """
 306
 307    private static let unassignUserMutation = """
 308    mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
 309        unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
 310    }
 311    """
 312
 313    private static let cancelBuildMutation = """
 314    mutation cancel($id: Int!) {
 315        cancel(jobId: $id) { id }
 316    }
 317    """
 318
 319    init(currentUser: User, client: SRHTClient, systemStatusRepository: SystemStatusRepository) {
 320        self.currentUser = currentUser
 321        self.client = client
 322        self.systemStatusRepository = systemStatusRepository
 323        self.projectService = ProjectService(client: client)
 324    }
 325
 326    func loadDashboard() async {
 327        isLoadingProjects = true
 328        isLoadingAssignedTickets = true
 329        isLoadingRecentBuilds = true
 330        isLoadingSystemStatus = true
 331        assignedTicketsError = nil
 332        recentBuildsError = nil
 333        isShowingStaleSystemStatus = false
 334        systemStatusErrorMessage = nil
 335
 336        async let projectsTask = loadProjects()
 337        async let jobsTask = loadRecentJobs()
 338        async let assignedTicketsTask = loadAssignedTickets()
 339        async let inboxUnreadTask = loadInboxUnreadSnapshot()
 340        async let systemStatusTask = loadSystemStatusSnapshot()
 341
 342        let projectsResult = await projectsTask
 343        switch projectsResult {
 344        case .success(let projects):
 345            self.projects = projects
 346        case .failure:
 347            self.projects = []
 348        }
 349        isLoadingProjects = false
 350
 351        let recentJobsResult = await jobsTask
 352
 353        switch recentJobsResult {
 354        case .success(let recentJobs):
 355            let buildItems = Self.buildItems(from: recentJobs)
 356            self.recentBuilds = buildItems
 357            self.recentBuildsError = nil
 358        case .failure(let error):
 359            self.recentBuilds = []
 360            self.recentBuildsError = error.userFacingMessage
 361        }
 362        isLoadingRecentBuilds = false
 363
 364        let assignedTicketsResult = await assignedTicketsTask
 365
 366        switch assignedTicketsResult {
 367        case .success(let assignedTickets):
 368            self.assignedTickets = assignedTickets
 369            self.assignedTicketsError = nil
 370        case .failure(let error):
 371            self.assignedTickets = []
 372            self.assignedTicketsError = error.userFacingMessage
 373        }
 374        isLoadingAssignedTickets = false
 375
 376        let inboxUnreadSnapshot = await inboxUnreadTask
 377        unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
 378        unreadInboxThreads = Array(inboxUnreadSnapshot?.threads.prefix(4) ?? [])
 379        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 380        let systemStatusResult = await systemStatusTask
 381        switch systemStatusResult {
 382        case .success(let result):
 383            systemStatusSnapshot = result.value
 384            isShowingStaleSystemStatus = result.isStale
 385            systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
 386        case .failure(let error):
 387            systemStatusErrorMessage = error.userFacingMessage
 388        }
 389        isLoadingSystemStatus = false
 390        persistNeedsAttentionSnapshot()
 391    }
 392
 393    var hasDashboardContent: Bool {
 394        !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
 395    }
 396
 397    var failedBuildCount: Int {
 398        recentBuilds.filter {
 399            switch $0.job.status {
 400            case .failed, .timeout:
 401                return true
 402            default:
 403                return false
 404            }
 405        }.count
 406    }
 407
 408    var activeBuildCount: Int {
 409        recentBuilds.filter {
 410            switch $0.job.status {
 411            case .pending, .queued, .running:
 412                return true
 413            default:
 414                return false
 415            }
 416        }.count
 417    }
 418
 419    var activeIncidentCount: Int {
 420        systemStatusSnapshot?.activeIncidents.count ?? 0
 421    }
 422
 423    var disruptedServiceCount: Int {
 424        systemStatusSnapshot?.disruptedServices.count ?? 0
 425    }
 426
 427    var needsAttentionCount: Int {
 428        var count = 0
 429        if let unreadInboxThreadCount {
 430            count += unreadInboxThreadCount
 431        }
 432        count += assignedTickets.count
 433        count += failedBuildCount
 434        count += activeBuildCount
 435        if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
 436            count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
 437        }
 438        return count
 439    }
 440
 441    var attentionSummaryText: String {
 442        if needsAttentionCount == 0 {
 443            return "All clear"
 444        }
 445        var parts: [String] = []
 446        if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
 447            parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
 448        }
 449        if !assignedTickets.isEmpty {
 450            parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
 451        }
 452        if failedBuildCount > 0 {
 453            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 454        }
 455        if activeBuildCount > 0 {
 456            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 457        }
 458        if disruptedServiceCount > 0 {
 459            parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
 460        }
 461        return parts.joined(separator: "")
 462    }
 463
 464    var inboxSummaryText: String {
 465        guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
 466        if unreadInboxThreadCount == 0 {
 467            return "Inbox zero"
 468        }
 469        return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
 470    }
 471
 472    var ticketsSummaryText: String {
 473        if assignedTickets.isEmpty {
 474            return "No open tickets assigned to you"
 475        }
 476        return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
 477    }
 478
 479    var buildsSummaryText: String {
 480        if recentBuilds.isEmpty {
 481            return "No recent builds"
 482        }
 483        var parts: [String] = []
 484        if failedBuildCount > 0 {
 485            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 486        }
 487        if activeBuildCount > 0 {
 488            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 489        }
 490        if parts.isEmpty {
 491            return "Recent builds are clear"
 492        }
 493        return parts.joined(separator: "")
 494    }
 495
 496    var systemSummaryText: String {
 497        guard let systemStatusSnapshot else {
 498            return systemStatusErrorMessage ?? "System status unavailable"
 499        }
 500        if systemStatusSnapshot.hasDisruption {
 501            return systemStatusSnapshot.bannerSummary
 502        }
 503        return systemStatusSnapshot.overallStatusText
 504    }
 505
 506    func resolveTicket(_ ticket: HomeAssignedTicket) async {
 507        let input: [String: any Sendable] = [
 508            "status": TicketStatus.resolved.rawValue,
 509            "resolution": TicketResolution.fixed.rawValue
 510        ]
 511        await performTicketStatusUpdate(ticket: ticket, input: input)
 512    }
 513
 514    func reopenTicket(_ ticket: HomeAssignedTicket) async {
 515        let input: [String: any Sendable] = [
 516            "status": TicketStatus.reported.rawValue
 517        ]
 518        await performTicketStatusUpdate(ticket: ticket, input: input)
 519    }
 520
 521    func unassignFromMe(_ ticket: HomeAssignedTicket) async {
 522        do {
 523            _ = try await client.execute(
 524                service: .todo,
 525                query: Self.unassignUserMutation,
 526                variables: [
 527                    "trackerId": ticket.trackerId,
 528                    "ticketId": ticket.ticket.id,
 529                    "userId": currentUser.id
 530                ],
 531                responseType: UnassignResponse.self
 532            )
 533            assignedTickets.removeAll { $0.id == ticket.id }
 534            persistNeedsAttentionSnapshot()
 535        } catch {
 536            homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
 537        }
 538    }
 539
 540    func cancelBuild(_ build: HomeBuildItem) async {
 541        guard build.job.status.isCancellable else { return }
 542
 543        do {
 544            _ = try await client.execute(
 545                service: .builds,
 546                query: Self.cancelBuildMutation,
 547                variables: ["id": build.job.id],
 548                responseType: CancelBuildResponse.self
 549            )
 550            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
 551                let updatedJob = JobSummary(
 552                    id: build.job.id,
 553                    created: build.job.created,
 554                    updated: build.job.updated,
 555                    status: .cancelled,
 556                    note: build.job.note,
 557                    tags: build.job.tags,
 558                    visibility: build.job.visibility,
 559                    image: build.job.image,
 560                    tasks: build.job.tasks
 561                )
 562                recentBuilds[index] = HomeBuildItem(
 563                    job: updatedJob,
 564                    repositoryName: build.repositoryName,
 565                    repositoryOwner: build.repositoryOwner
 566                )
 567            }
 568            persistNeedsAttentionSnapshot()
 569        } catch {
 570            homeLogger.error("Cancel build failed: \(error, privacy: .public)")
 571        }
 572    }
 573
 574    func markInboxThreadRead(_ thread: InboxThreadSummary) {
 575        InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
 576        unreadInboxThreads.removeAll { $0.id == thread.id }
 577        unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
 578        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 579        persistNeedsAttentionSnapshot()
 580    }
 581
 582    func markInboxThreadUnread(_ thread: InboxThreadSummary) {
 583        InboxReadStateStore.markUnread(for: thread.id)
 584        if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
 585            unreadInboxThreads.append(
 586                InboxThreadSummary(
 587                    rootEmailID: thread.rootEmailID,
 588                    rootMessageID: thread.rootMessageID,
 589                    threadRootEmailIDs: thread.threadRootEmailIDs,
 590                    threadRootMessageIDs: thread.threadRootMessageIDs,
 591                    listID: thread.listID,
 592                    listRID: thread.listRID,
 593                    listName: thread.listName,
 594                    listOwner: thread.listOwner,
 595                    subject: thread.subject,
 596                    latestSender: thread.latestSender,
 597                    lastActivityAt: thread.lastActivityAt,
 598                    messageCount: thread.messageCount,
 599                    repo: thread.repo,
 600                    containsPatch: thread.containsPatch,
 601                    isUnread: true
 602                )
 603            )
 604            unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
 605        }
 606        unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
 607        hasUnreadInboxThreads = true
 608        persistNeedsAttentionSnapshot()
 609    }
 610
 611    private func loadProjects() async -> Result<[Project], Error> {
 612        do {
 613            return .success(try await projectService.fetchProjects())
 614        } catch {
 615            return .failure(error)
 616        }
 617    }
 618
 619    private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
 620        do {
 621            let response = try await client.execute(
 622                service: .builds,
 623                query: Self.jobsQuery,
 624                responseType: HomeJobsResponse.self
 625            )
 626            return .success(response.jobs.results)
 627        } catch {
 628            return .failure(error)
 629        }
 630    }
 631
 632    private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
 633        do {
 634            return try await fetchUnreadInboxSnapshot()
 635        } catch {
 636            return nil
 637        }
 638    }
 639
 640    private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
 641        do {
 642            return .success(try await systemStatusRepository.snapshotResult())
 643        } catch {
 644            return .failure(error)
 645        }
 646    }
 647
 648    private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
 649        let mailingLists = try await fetchInboxMailingLists()
 650        guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
 651
 652        var startIndex = mailingLists.startIndex
 653        var unreadCount = 0
 654        var successfulFetchCount = 0
 655        var unreadThreads: [InboxThreadSummary] = []
 656        while startIndex < mailingLists.endIndex {
 657            let endIndex = mailingLists.index(
 658                startIndex,
 659                offsetBy: inboxUnreadConcurrencyLimit,
 660                limitedBy: mailingLists.endIndex
 661            ) ?? mailingLists.endIndex
 662            let batch = Array(mailingLists[startIndex..<endIndex])
 663
 664            let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
 665                for mailingList in batch {
 666                    group.addTask {
 667                        do {
 668                            return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
 669                        } catch {
 670                            return .failure(error)
 671                        }
 672                    }
 673                }
 674
 675                var snapshots: [HomeInboxUnreadSnapshot] = []
 676                var errors: [Error] = []
 677                for await result in group {
 678                    switch result {
 679                    case .success(let snapshot):
 680                        snapshots.append(snapshot)
 681                    case .failure(let error):
 682                        errors.append(error)
 683                    }
 684                }
 685                return (snapshots, errors)
 686            }
 687
 688            unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
 689            unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
 690            successfulFetchCount += batchResult.0.count
 691
 692            startIndex = endIndex
 693        }
 694
 695        guard successfulFetchCount > 0 else {
 696            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
 697        }
 698
 699        return HomeInboxUnreadSnapshot(
 700            unreadCount: unreadCount,
 701            threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
 702        )
 703    }
 704
 705    private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
 706        var subscriptions: [HomeInboxSubscription] = []
 707        var cursor: String?
 708
 709        while true {
 710            var variables: [String: any Sendable] = [:]
 711            if let cursor {
 712                variables["cursor"] = cursor
 713            }
 714
 715            let response = try await client.execute(
 716                service: .lists,
 717                query: Self.inboxSubscriptionsQuery,
 718                variables: variables.isEmpty ? nil : variables,
 719                responseType: HomeInboxSubscriptionsResponse.self
 720            )
 721
 722            subscriptions.append(contentsOf: response.subscriptions.results)
 723            guard let nextCursor = response.subscriptions.cursor else {
 724                break
 725            }
 726            cursor = nextCursor
 727        }
 728
 729        var seen = Set<String>()
 730        return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
 731    }
 732
 733    private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
 734        var unreadCount = 0
 735        var cursor: String?
 736        var unreadThreads: [InboxThreadSummary] = []
 737
 738        while true {
 739            var variables: [String: any Sendable] = ["rid": mailingList.rid]
 740            if let cursor {
 741                variables["cursor"] = cursor
 742            }
 743
 744            let response = try await client.execute(
 745                service: .lists,
 746                query: Self.inboxListThreadsQuery,
 747                variables: variables,
 748                responseType: HomeInboxListThreadsResponse.self
 749            )
 750
 751            let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
 752                let summary = InboxThreadSummary(
 753                    rootEmailID: thread.root.id,
 754                    rootMessageID: thread.root.messageID,
 755                    threadRootEmailIDs: [thread.root.id],
 756                    threadRootMessageIDs: [thread.root.messageID],
 757                    listID: mailingList.id,
 758                    listRID: mailingList.rid,
 759                    listName: mailingList.name,
 760                    listOwner: mailingList.owner,
 761                    subject: thread.subject,
 762                    latestSender: thread.sender,
 763                    lastActivityAt: thread.updated,
 764                    messageCount: thread.replies + 1,
 765                    repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
 766                    containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
 767                    isUnread: InboxReadStateStore.isUnread(
 768                        threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
 769                        lastActivityAt: thread.updated
 770                    )
 771                )
 772                return summary.isUnread ? summary : nil
 773            }
 774            unreadCount += unreadThreadSummaries.count
 775            unreadThreads.append(contentsOf: unreadThreadSummaries)
 776
 777            guard let nextCursor = response.list.threads.cursor else {
 778                break
 779            }
 780            cursor = nextCursor
 781        }
 782
 783        return HomeInboxUnreadSnapshot(
 784            unreadCount: unreadCount,
 785            threads: unreadThreads
 786        )
 787    }
 788
 789    private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
 790        do {
 791            let trackers = try await fetchAllTrackers()
 792            let tickets = try await fetchAssignedTickets(for: trackers)
 793                .sorted(by: Self.sortAssignedTicketsForTriage)
 794            return .success(tickets)
 795        } catch {
 796            return .failure(error)
 797        }
 798    }
 799
 800    private func fetchAllTrackers() async throws -> [TrackerSummary] {
 801        var allTrackers: [TrackerSummary] = []
 802        var cursor: String?
 803
 804        while true {
 805            var variables: [String: any Sendable] = [:]
 806            if let cursor {
 807                variables["cursor"] = cursor
 808            }
 809
 810            let response = try await client.execute(
 811                service: .todo,
 812                query: Self.trackersQuery,
 813                variables: variables.isEmpty ? nil : variables,
 814                responseType: HomeTrackersResponse.self
 815            )
 816
 817            allTrackers.append(contentsOf: response.trackers.results)
 818            guard let nextCursor = response.trackers.cursor else {
 819                break
 820            }
 821            cursor = nextCursor
 822        }
 823
 824        return allTrackers
 825    }
 826
 827    private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
 828        guard !trackers.isEmpty else { return [] }
 829
 830        var assignedTickets: [HomeAssignedTicket] = []
 831        var startIndex = trackers.startIndex
 832
 833        while startIndex < trackers.endIndex {
 834            let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
 835            let batch = Array(trackers[startIndex..<endIndex])
 836
 837            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
 838                for tracker in batch {
 839                    group.addTask {
 840                        try await self.fetchAssignedTickets(for: tracker)
 841                    }
 842                }
 843
 844                var ticketsForBatch: [HomeAssignedTicket] = []
 845                for try await tickets in group {
 846                    ticketsForBatch.append(contentsOf: tickets)
 847                }
 848                return ticketsForBatch
 849            }
 850
 851            assignedTickets.append(contentsOf: batchTickets)
 852            startIndex = endIndex
 853        }
 854
 855        return assignedTickets
 856    }
 857
 858    private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
 859        let response = try await client.execute(
 860            service: .todo,
 861            query: Self.trackerTicketsQuery,
 862            variables: [
 863                "owner": tracker.owner.canonicalName.hasPrefix("~")
 864                    ? String(tracker.owner.canonicalName.dropFirst())
 865                    : tracker.owner.canonicalName,
 866                "tracker": tracker.name
 867            ],
 868            responseType: HomeTrackerTicketsResponse.self
 869        )
 870
 871        return response.user.tracker.tickets.results.compactMap { payload in
 872            guard payload.status.isOpen else {
 873                return nil
 874            }
 875            guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
 876                return nil
 877            }
 878
 879            return HomeAssignedTicket(
 880                trackerId: tracker.id,
 881                trackerRid: tracker.rid,
 882                trackerName: tracker.name,
 883                ownerCanonicalName: tracker.owner.canonicalName,
 884                ticket: payload.ticketSummary
 885            )
 886        }
 887    }
 888
 889    private func performTicketStatusUpdate(
 890        ticket: HomeAssignedTicket,
 891        input: [String: any Sendable]
 892    ) async {
 893        do {
 894            _ = try await client.execute(
 895                service: .todo,
 896                query: Self.updateTicketStatusMutation,
 897                variables: [
 898                    "trackerId": ticket.trackerId,
 899                    "ticketId": ticket.ticket.id,
 900                    "input": input
 901                ],
 902                responseType: StatusEventResponse.self
 903            )
 904            assignedTickets.removeAll { $0.id == ticket.id }
 905            persistNeedsAttentionSnapshot()
 906        } catch {
 907            homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
 908        }
 909    }
 910
 911    private func persistNeedsAttentionSnapshot() {
 912        NeedsAttentionSnapshotStore.save(
 913            NeedsAttentionSnapshot(
 914                unreadInboxThreads: unreadInboxThreadCount,
 915                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
 916                failedBuilds: recentBuildsError == nil
 917                    ? recentBuilds.filter {
 918                        switch $0.job.status {
 919                        case .failed, .timeout:
 920                            true
 921                        default:
 922                            false
 923                        }
 924                    }.count
 925                    : nil,
 926                updatedAt: .now
 927            )
 928        )
 929    }
 930
 931    nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
 932        jobs.map { job in
 933            let repository = primaryRepositoryReference(in: job.manifest)
 934            return HomeBuildItem(
 935                job: job.jobSummary,
 936                repositoryName: repository?.name,
 937                repositoryOwner: repository?.ownerCanonicalName
 938            )
 939        }
 940        .sorted(by: sortBuildItemsForTriage)
 941    }
 942
 943    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
 944        buildItems(from: jobs).filter { build in
 945            switch build.job.status {
 946            case .failed, .timeout:
 947                true
 948            default:
 949                false
 950            }
 951        }
 952    }
 953
 954    nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
 955        let lhsPriority = buildPriority(for: lhs.job.status)
 956        let rhsPriority = buildPriority(for: rhs.job.status)
 957        if lhsPriority != rhsPriority {
 958            return lhsPriority < rhsPriority
 959        }
 960        if lhs.job.updated != rhs.job.updated {
 961            return lhs.job.updated > rhs.job.updated
 962        }
 963        return lhs.job.id > rhs.job.id
 964    }
 965
 966    nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
 967        if lhs.ticket.created != rhs.ticket.created {
 968            return lhs.ticket.created < rhs.ticket.created
 969        }
 970        return lhs.ticket.id < rhs.ticket.id
 971    }
 972
 973    nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
 974        if lhs.containsPatch != rhs.containsPatch {
 975            return lhs.containsPatch && !rhs.containsPatch
 976        }
 977        if lhs.lastActivityAt != rhs.lastActivityAt {
 978            return lhs.lastActivityAt > rhs.lastActivityAt
 979        }
 980        return InboxThreadSummary.normalizationKey(for: lhs.subject)
 981            .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
 982    }
 983
 984    nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
 985        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
 986        let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
 987        if assigneeCanonical == currentCanonical {
 988            return true
 989        }
 990
 991        let assigneeUsername = normalizedUsername(entity.canonicalName)
 992        let currentUsername = normalizedUsername(currentUser.username)
 993        return assigneeUsername == currentUsername
 994    }
 995
 996    nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
 997        guard let manifest else { return nil }
 998        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
 999        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1000            return nil
1001        }
1002        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1003        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1004              let ownerRange = Range(match.range(at: 1), in: manifest),
1005              let nameRange = Range(match.range(at: 2), in: manifest) else {
1006            return nil
1007        }
1008
1009        let owner = String(manifest[ownerRange])
1010        var name = String(manifest[nameRange])
1011        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1012            name.removeSubrange(suffixRange)
1013        }
1014        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1015        guard !name.isEmpty else { return nil }
1016        return (owner, name)
1017    }
1018
1019    private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1020        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1021        guard !trimmed.isEmpty else { return trimmed }
1022        if trimmed.hasPrefix("~") {
1023            return trimmed
1024        }
1025        return "~\(trimmed)"
1026    }
1027
1028    private nonisolated static func normalizedUsername(_ value: String) -> String {
1029        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1030        if trimmed.hasPrefix("~") {
1031            return String(trimmed.dropFirst())
1032        }
1033        return trimmed
1034    }
1035
1036    private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1037        switch status {
1038        case .failed, .timeout:
1039            return 0
1040        case .running:
1041            return 1
1042        case .queued, .pending:
1043            return 2
1044        case .cancelled:
1045            return 3
1046        case .success:
1047            return 4
1048        }
1049    }
1050
1051    private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1052        count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1053    }
1054
1055    private struct StatusEventResponse: Decodable, Sendable {
1056        struct EventRef: Decodable, Sendable {
1057            let eventType: String
1058        }
1059
1060        let updateTicketStatus: EventRef
1061    }
1062
1063    private struct UnassignResponse: Decodable, Sendable {
1064        struct EventRef: Decodable, Sendable {
1065            let id: Int
1066        }
1067
1068        let unassignUser: EventRef
1069    }
1070
1071    private struct CancelBuildResponse: Decodable, Sendable {
1072        struct CancelResult: Decodable, Sendable {
1073            let id: Int
1074        }
1075
1076        let cancel: CancelResult
1077    }
1078}
1079struct HomeJobPayload: Decodable, Sendable {
1080    let id: Int
1081    let created: Date
1082    let updated: Date
1083    let status: JobStatus
1084    let note: String?
1085    let tags: [String]
1086    let visibility: Visibility?
1087    let image: String?
1088    let tasks: [JobTaskSummary]
1089    let manifest: String?
1090
1091    nonisolated var jobSummary: JobSummary {
1092        JobSummary(
1093            id: id,
1094            created: created,
1095            updated: updated,
1096            status: status,
1097            note: note,
1098            tags: tags,
1099            visibility: visibility,
1100            image: image,
1101            tasks: tasks
1102        )
1103    }
1104}