krz/hutch

an ios client for sourcehut

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

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