krz/hutch

an ios client for sourcehut

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

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