krz/hutch

an ios client for sourcehut

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

v2.15.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        persistSystemStatusWidgetSnapshot()
 392    }
 393
 394    var hasDashboardContent: Bool {
 395        !projects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
 396    }
 397
 398    var failedBuildCount: Int {
 399        recentBuilds.filter {
 400            switch $0.job.status {
 401            case .failed, .timeout:
 402                return true
 403            default:
 404                return false
 405            }
 406        }.count
 407    }
 408
 409    var activeBuildCount: Int {
 410        recentBuilds.filter {
 411            switch $0.job.status {
 412            case .pending, .queued, .running:
 413                return true
 414            default:
 415                return false
 416            }
 417        }.count
 418    }
 419
 420    var activeIncidentCount: Int {
 421        systemStatusSnapshot?.activeIncidents.count ?? 0
 422    }
 423
 424    var disruptedServiceCount: Int {
 425        systemStatusSnapshot?.disruptedServices.count ?? 0
 426    }
 427
 428    var needsAttentionCount: Int {
 429        var count = 0
 430        if let unreadInboxThreadCount {
 431            count += unreadInboxThreadCount
 432        }
 433        count += assignedTickets.count
 434        count += failedBuildCount
 435        count += activeBuildCount
 436        if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
 437            count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
 438        }
 439        return count
 440    }
 441
 442    var attentionSummaryText: String {
 443        if needsAttentionCount == 0 {
 444            return "All clear"
 445        }
 446        var parts: [String] = []
 447        if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
 448            parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
 449        }
 450        if !assignedTickets.isEmpty {
 451            parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
 452        }
 453        if failedBuildCount > 0 {
 454            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 455        }
 456        if activeBuildCount > 0 {
 457            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 458        }
 459        if disruptedServiceCount > 0 {
 460            parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
 461        }
 462        return parts.joined(separator: "")
 463    }
 464
 465    var inboxSummaryText: String {
 466        guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
 467        if unreadInboxThreadCount == 0 {
 468            return "Inbox zero"
 469        }
 470        return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
 471    }
 472
 473    var ticketsSummaryText: String {
 474        if assignedTickets.isEmpty {
 475            return "No open tickets assigned to you"
 476        }
 477        return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
 478    }
 479
 480    var buildsSummaryText: String {
 481        if recentBuilds.isEmpty {
 482            return "No recent builds"
 483        }
 484        var parts: [String] = []
 485        if failedBuildCount > 0 {
 486            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 487        }
 488        if activeBuildCount > 0 {
 489            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 490        }
 491        if parts.isEmpty {
 492            return "Recent builds are clear"
 493        }
 494        return parts.joined(separator: "")
 495    }
 496
 497    var systemSummaryText: String {
 498        guard let systemStatusSnapshot else {
 499            return systemStatusErrorMessage ?? "System status unavailable"
 500        }
 501        if systemStatusSnapshot.hasDisruption {
 502            return systemStatusSnapshot.bannerSummary
 503        }
 504        return systemStatusSnapshot.overallStatusText
 505    }
 506
 507    func resolveTicket(_ ticket: HomeAssignedTicket) async {
 508        let input: [String: any Sendable] = [
 509            "status": TicketStatus.resolved.rawValue,
 510            "resolution": TicketResolution.fixed.rawValue
 511        ]
 512        await performTicketStatusUpdate(ticket: ticket, input: input)
 513    }
 514
 515    func reopenTicket(_ ticket: HomeAssignedTicket) async {
 516        let input: [String: any Sendable] = [
 517            "status": TicketStatus.reported.rawValue
 518        ]
 519        await performTicketStatusUpdate(ticket: ticket, input: input)
 520    }
 521
 522    func unassignFromMe(_ ticket: HomeAssignedTicket) async {
 523        do {
 524            _ = try await client.execute(
 525                service: .todo,
 526                query: Self.unassignUserMutation,
 527                variables: [
 528                    "trackerId": ticket.trackerId,
 529                    "ticketId": ticket.ticket.id,
 530                    "userId": currentUser.id
 531                ],
 532                responseType: UnassignResponse.self
 533            )
 534            assignedTickets.removeAll { $0.id == ticket.id }
 535            persistNeedsAttentionSnapshot()
 536        } catch {
 537            homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
 538        }
 539    }
 540
 541    func cancelBuild(_ build: HomeBuildItem) async {
 542        guard build.job.status.isCancellable else { return }
 543
 544        do {
 545            _ = try await client.execute(
 546                service: .builds,
 547                query: Self.cancelBuildMutation,
 548                variables: ["id": build.job.id],
 549                responseType: CancelBuildResponse.self
 550            )
 551            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
 552                let updatedJob = JobSummary(
 553                    id: build.job.id,
 554                    created: build.job.created,
 555                    updated: build.job.updated,
 556                    status: .cancelled,
 557                    note: build.job.note,
 558                    tags: build.job.tags,
 559                    visibility: build.job.visibility,
 560                    image: build.job.image,
 561                    tasks: build.job.tasks
 562                )
 563                recentBuilds[index] = HomeBuildItem(
 564                    job: updatedJob,
 565                    repositoryName: build.repositoryName,
 566                    repositoryOwner: build.repositoryOwner
 567                )
 568            }
 569            persistNeedsAttentionSnapshot()
 570        } catch {
 571            homeLogger.error("Cancel build failed: \(error, privacy: .public)")
 572        }
 573    }
 574
 575    func markInboxThreadRead(_ thread: InboxThreadSummary) {
 576        InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
 577        unreadInboxThreads.removeAll { $0.id == thread.id }
 578        unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
 579        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 580        persistNeedsAttentionSnapshot()
 581    }
 582
 583    func markInboxThreadUnread(_ thread: InboxThreadSummary) {
 584        InboxReadStateStore.markUnread(for: thread.id)
 585        if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
 586            unreadInboxThreads.append(
 587                InboxThreadSummary(
 588                    rootEmailID: thread.rootEmailID,
 589                    rootMessageID: thread.rootMessageID,
 590                    threadRootEmailIDs: thread.threadRootEmailIDs,
 591                    threadRootMessageIDs: thread.threadRootMessageIDs,
 592                    listID: thread.listID,
 593                    listRID: thread.listRID,
 594                    listName: thread.listName,
 595                    listOwner: thread.listOwner,
 596                    subject: thread.subject,
 597                    latestSender: thread.latestSender,
 598                    lastActivityAt: thread.lastActivityAt,
 599                    messageCount: thread.messageCount,
 600                    repo: thread.repo,
 601                    containsPatch: thread.containsPatch,
 602                    isUnread: true
 603                )
 604            )
 605            unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
 606        }
 607        unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
 608        hasUnreadInboxThreads = true
 609        persistNeedsAttentionSnapshot()
 610    }
 611
 612    private func loadProjects() async -> Result<[Project], Error> {
 613        do {
 614            return .success(try await projectService.fetchProjects())
 615        } catch {
 616            return .failure(error)
 617        }
 618    }
 619
 620    private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
 621        do {
 622            let response = try await client.execute(
 623                service: .builds,
 624                query: Self.jobsQuery,
 625                responseType: HomeJobsResponse.self
 626            )
 627            return .success(response.jobs.results)
 628        } catch {
 629            return .failure(error)
 630        }
 631    }
 632
 633    private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
 634        do {
 635            return try await fetchUnreadInboxSnapshot()
 636        } catch {
 637            return nil
 638        }
 639    }
 640
 641    private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
 642        do {
 643            return .success(try await systemStatusRepository.snapshotResult())
 644        } catch {
 645            return .failure(error)
 646        }
 647    }
 648
 649    private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
 650        let mailingLists = try await fetchInboxMailingLists()
 651        guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
 652
 653        var startIndex = mailingLists.startIndex
 654        var unreadCount = 0
 655        var successfulFetchCount = 0
 656        var unreadThreads: [InboxThreadSummary] = []
 657        while startIndex < mailingLists.endIndex {
 658            let endIndex = mailingLists.index(
 659                startIndex,
 660                offsetBy: inboxUnreadConcurrencyLimit,
 661                limitedBy: mailingLists.endIndex
 662            ) ?? mailingLists.endIndex
 663            let batch = Array(mailingLists[startIndex..<endIndex])
 664
 665            let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
 666                for mailingList in batch {
 667                    group.addTask {
 668                        do {
 669                            return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
 670                        } catch {
 671                            return .failure(error)
 672                        }
 673                    }
 674                }
 675
 676                var snapshots: [HomeInboxUnreadSnapshot] = []
 677                var errors: [Error] = []
 678                for await result in group {
 679                    switch result {
 680                    case .success(let snapshot):
 681                        snapshots.append(snapshot)
 682                    case .failure(let error):
 683                        errors.append(error)
 684                    }
 685                }
 686                return (snapshots, errors)
 687            }
 688
 689            unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
 690            unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
 691            successfulFetchCount += batchResult.0.count
 692
 693            startIndex = endIndex
 694        }
 695
 696        guard successfulFetchCount > 0 else {
 697            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
 698        }
 699
 700        return HomeInboxUnreadSnapshot(
 701            unreadCount: unreadCount,
 702            threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
 703        )
 704    }
 705
 706    private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
 707        var subscriptions: [HomeInboxSubscription] = []
 708        var cursor: String?
 709
 710        while true {
 711            var variables: [String: any Sendable] = [:]
 712            if let cursor {
 713                variables["cursor"] = cursor
 714            }
 715
 716            let response = try await client.execute(
 717                service: .lists,
 718                query: Self.inboxSubscriptionsQuery,
 719                variables: variables.isEmpty ? nil : variables,
 720                responseType: HomeInboxSubscriptionsResponse.self
 721            )
 722
 723            subscriptions.append(contentsOf: response.subscriptions.results)
 724            guard let nextCursor = response.subscriptions.cursor else {
 725                break
 726            }
 727            cursor = nextCursor
 728        }
 729
 730        var seen = Set<String>()
 731        return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
 732    }
 733
 734    private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
 735        var unreadCount = 0
 736        var cursor: String?
 737        var unreadThreads: [InboxThreadSummary] = []
 738
 739        while true {
 740            var variables: [String: any Sendable] = ["rid": mailingList.rid]
 741            if let cursor {
 742                variables["cursor"] = cursor
 743            }
 744
 745            let response = try await client.execute(
 746                service: .lists,
 747                query: Self.inboxListThreadsQuery,
 748                variables: variables,
 749                responseType: HomeInboxListThreadsResponse.self
 750            )
 751
 752            let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
 753                let summary = InboxThreadSummary(
 754                    rootEmailID: thread.root.id,
 755                    rootMessageID: thread.root.messageID,
 756                    threadRootEmailIDs: [thread.root.id],
 757                    threadRootMessageIDs: [thread.root.messageID],
 758                    listID: mailingList.id,
 759                    listRID: mailingList.rid,
 760                    listName: mailingList.name,
 761                    listOwner: mailingList.owner,
 762                    subject: thread.subject,
 763                    latestSender: thread.sender,
 764                    lastActivityAt: thread.updated,
 765                    messageCount: thread.replies + 1,
 766                    repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
 767                    containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
 768                    isUnread: InboxReadStateStore.isUnread(
 769                        threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
 770                        lastActivityAt: thread.updated
 771                    )
 772                )
 773                return summary.isUnread ? summary : nil
 774            }
 775            unreadCount += unreadThreadSummaries.count
 776            unreadThreads.append(contentsOf: unreadThreadSummaries)
 777
 778            guard let nextCursor = response.list.threads.cursor else {
 779                break
 780            }
 781            cursor = nextCursor
 782        }
 783
 784        return HomeInboxUnreadSnapshot(
 785            unreadCount: unreadCount,
 786            threads: unreadThreads
 787        )
 788    }
 789
 790    private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
 791        do {
 792            let trackers = try await fetchAllTrackers()
 793            let tickets = try await fetchAssignedTickets(for: trackers)
 794                .sorted(by: Self.sortAssignedTicketsForTriage)
 795            return .success(tickets)
 796        } catch {
 797            return .failure(error)
 798        }
 799    }
 800
 801    private func fetchAllTrackers() async throws -> [TrackerSummary] {
 802        var allTrackers: [TrackerSummary] = []
 803        var cursor: String?
 804
 805        while true {
 806            var variables: [String: any Sendable] = [:]
 807            if let cursor {
 808                variables["cursor"] = cursor
 809            }
 810
 811            let response = try await client.execute(
 812                service: .todo,
 813                query: Self.trackersQuery,
 814                variables: variables.isEmpty ? nil : variables,
 815                responseType: HomeTrackersResponse.self
 816            )
 817
 818            allTrackers.append(contentsOf: response.trackers.results)
 819            guard let nextCursor = response.trackers.cursor else {
 820                break
 821            }
 822            cursor = nextCursor
 823        }
 824
 825        return allTrackers
 826    }
 827
 828    private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
 829        guard !trackers.isEmpty else { return [] }
 830
 831        var assignedTickets: [HomeAssignedTicket] = []
 832        var startIndex = trackers.startIndex
 833
 834        while startIndex < trackers.endIndex {
 835            let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
 836            let batch = Array(trackers[startIndex..<endIndex])
 837
 838            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
 839                for tracker in batch {
 840                    group.addTask {
 841                        try await self.fetchAssignedTickets(for: tracker)
 842                    }
 843                }
 844
 845                var ticketsForBatch: [HomeAssignedTicket] = []
 846                for try await tickets in group {
 847                    ticketsForBatch.append(contentsOf: tickets)
 848                }
 849                return ticketsForBatch
 850            }
 851
 852            assignedTickets.append(contentsOf: batchTickets)
 853            startIndex = endIndex
 854        }
 855
 856        return assignedTickets
 857    }
 858
 859    private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
 860        let response = try await client.execute(
 861            service: .todo,
 862            query: Self.trackerTicketsQuery,
 863            variables: [
 864                "owner": tracker.owner.canonicalName.hasPrefix("~")
 865                    ? String(tracker.owner.canonicalName.dropFirst())
 866                    : tracker.owner.canonicalName,
 867                "tracker": tracker.name
 868            ],
 869            responseType: HomeTrackerTicketsResponse.self
 870        )
 871
 872        return response.user.tracker.tickets.results.compactMap { payload in
 873            guard payload.status.isOpen else {
 874                return nil
 875            }
 876            guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
 877                return nil
 878            }
 879
 880            return HomeAssignedTicket(
 881                trackerId: tracker.id,
 882                trackerRid: tracker.rid,
 883                trackerName: tracker.name,
 884                ownerCanonicalName: tracker.owner.canonicalName,
 885                ticket: payload.ticketSummary
 886            )
 887        }
 888    }
 889
 890    private func performTicketStatusUpdate(
 891        ticket: HomeAssignedTicket,
 892        input: [String: any Sendable]
 893    ) async {
 894        do {
 895            _ = try await client.execute(
 896                service: .todo,
 897                query: Self.updateTicketStatusMutation,
 898                variables: [
 899                    "trackerId": ticket.trackerId,
 900                    "ticketId": ticket.ticket.id,
 901                    "input": input
 902                ],
 903                responseType: StatusEventResponse.self
 904            )
 905            assignedTickets.removeAll { $0.id == ticket.id }
 906            persistNeedsAttentionSnapshot()
 907        } catch {
 908            homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
 909        }
 910    }
 911
 912    private func persistNeedsAttentionSnapshot() {
 913        NeedsAttentionSnapshotStore.save(
 914            NeedsAttentionSnapshot(
 915                unreadInboxThreads: unreadInboxThreadCount,
 916                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
 917                failedBuilds: recentBuildsError == nil
 918                    ? recentBuilds.filter {
 919                        switch $0.job.status {
 920                        case .failed, .timeout:
 921                            true
 922                        default:
 923                            false
 924                        }
 925                    }.count
 926                    : nil,
 927                updatedAt: .now
 928            )
 929        )
 930    }
 931
 932    private func persistSystemStatusWidgetSnapshot() {
 933        guard let snapshot = systemStatusSnapshot else {
 934            return
 935        }
 936        let widgetSnapshot = SystemStatusWidgetSnapshot(
 937            services: snapshot.services.map { service in
 938                SystemStatusWidgetSnapshot.ServiceEntry(
 939                    id: service.id,
 940                    name: service.name,
 941                    status: service.status.displayName,
 942                    requiresAttention: service.status.requiresAttention
 943                )
 944            },
 945            hasDisruption: snapshot.hasDisruption,
 946            overallStatusText: snapshot.overallStatusText,
 947            bannerSummary: snapshot.bannerSummary,
 948            updatedAt: .now
 949        )
 950        SystemStatusWidgetSnapshotStore.save(widgetSnapshot)
 951    }
 952
 953    nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
 954        jobs.map { job in
 955            let repository = primaryRepositoryReference(in: job.manifest)
 956            return HomeBuildItem(
 957                job: job.jobSummary,
 958                repositoryName: repository?.name,
 959                repositoryOwner: repository?.ownerCanonicalName
 960            )
 961        }
 962        .sorted(by: sortBuildItemsForTriage)
 963    }
 964
 965    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
 966        buildItems(from: jobs).filter { build in
 967            switch build.job.status {
 968            case .failed, .timeout:
 969                true
 970            default:
 971                false
 972            }
 973        }
 974    }
 975
 976    nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
 977        let lhsPriority = buildPriority(for: lhs.job.status)
 978        let rhsPriority = buildPriority(for: rhs.job.status)
 979        if lhsPriority != rhsPriority {
 980            return lhsPriority < rhsPriority
 981        }
 982        if lhs.job.updated != rhs.job.updated {
 983            return lhs.job.updated > rhs.job.updated
 984        }
 985        return lhs.job.id > rhs.job.id
 986    }
 987
 988    nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
 989        if lhs.ticket.created != rhs.ticket.created {
 990            return lhs.ticket.created < rhs.ticket.created
 991        }
 992        return lhs.ticket.id < rhs.ticket.id
 993    }
 994
 995    nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
 996        if lhs.containsPatch != rhs.containsPatch {
 997            return lhs.containsPatch && !rhs.containsPatch
 998        }
 999        if lhs.lastActivityAt != rhs.lastActivityAt {
1000            return lhs.lastActivityAt > rhs.lastActivityAt
1001        }
1002        return InboxThreadSummary.normalizationKey(for: lhs.subject)
1003            .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1004    }
1005
1006    nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1007        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1008        let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1009        if assigneeCanonical == currentCanonical {
1010            return true
1011        }
1012
1013        let assigneeUsername = normalizedUsername(entity.canonicalName)
1014        let currentUsername = normalizedUsername(currentUser.username)
1015        return assigneeUsername == currentUsername
1016    }
1017
1018    nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1019        guard let manifest else { return nil }
1020        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1021        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1022            return nil
1023        }
1024        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1025        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1026              let ownerRange = Range(match.range(at: 1), in: manifest),
1027              let nameRange = Range(match.range(at: 2), in: manifest) else {
1028            return nil
1029        }
1030
1031        let owner = String(manifest[ownerRange])
1032        var name = String(manifest[nameRange])
1033        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1034            name.removeSubrange(suffixRange)
1035        }
1036        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1037        guard !name.isEmpty else { return nil }
1038        return (owner, name)
1039    }
1040
1041    private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1042        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1043        guard !trimmed.isEmpty else { return trimmed }
1044        if trimmed.hasPrefix("~") {
1045            return trimmed
1046        }
1047        return "~\(trimmed)"
1048    }
1049
1050    private nonisolated static func normalizedUsername(_ value: String) -> String {
1051        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1052        if trimmed.hasPrefix("~") {
1053            return String(trimmed.dropFirst())
1054        }
1055        return trimmed
1056    }
1057
1058    private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1059        switch status {
1060        case .failed, .timeout:
1061            return 0
1062        case .running:
1063            return 1
1064        case .queued, .pending:
1065            return 2
1066        case .cancelled:
1067            return 3
1068        case .success:
1069            return 4
1070        }
1071    }
1072
1073    private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1074        count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1075    }
1076
1077    private struct StatusEventResponse: Decodable, Sendable {
1078        struct EventRef: Decodable, Sendable {
1079            let eventType: String
1080        }
1081
1082        let updateTicketStatus: EventRef
1083    }
1084
1085    private struct UnassignResponse: Decodable, Sendable {
1086        struct EventRef: Decodable, Sendable {
1087            let id: Int
1088        }
1089
1090        let unassignUser: EventRef
1091    }
1092
1093    private struct CancelBuildResponse: Decodable, Sendable {
1094        struct CancelResult: Decodable, Sendable {
1095            let id: Int
1096        }
1097
1098        let cancel: CancelResult
1099    }
1100}
1101struct HomeJobPayload: Decodable, Sendable {
1102    let id: Int
1103    let created: Date
1104    let updated: Date
1105    let status: JobStatus
1106    let note: String?
1107    let tags: [String]
1108    let visibility: Visibility?
1109    let image: String?
1110    let tasks: [JobTaskSummary]
1111    let manifest: String?
1112
1113    nonisolated var jobSummary: JobSummary {
1114        JobSummary(
1115            id: id,
1116            created: created,
1117            updated: updated,
1118            status: status,
1119            note: note,
1120            tags: tags,
1121            visibility: visibility,
1122            image: image,
1123            tasks: tasks
1124        )
1125    }
1126}