krz/hutch

an ios client for sourcehut

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

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