krz/hutch

an ios client for sourcehut

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

v3.8.0: Hutch/Views/Home/HomeViewModel.swift · raw

   1import Foundation
   2import os
   3
   4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
   5
   6private struct HomeJobsResponse: Decodable, Sendable {
   7    let jobs: HomeJobsPage
   8}
   9
  10private struct HomeJobsPage: Decodable, Sendable {
  11    let results: [HomeJobPayload]
  12}
  13
  14private struct HomeTrackersResponse: Decodable, Sendable {
  15    let trackers: HomeTrackersPage
  16}
  17
  18private struct HomeTrackersPage: Decodable, Sendable {
  19    let results: [TrackerSummary]
  20    let cursor: String?
  21}
  22
  23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
  24    let user: HomeTrackerTicketsUser
  25}
  26
  27private struct HomeTrackerTicketsUser: Decodable, Sendable {
  28    let tracker: HomeTrackerTicketsTracker
  29}
  30
  31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
  32    let tickets: HomeTrackerTicketsPage
  33}
  34
  35private struct HomeTrackerTicketsPage: Decodable, Sendable {
  36    let results: [HomeTicketPayload]
  37}
  38
  39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
  40    let subscriptions: HomeInboxSubscriptionPage
  41}
  42
  43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
  44    let results: [HomeInboxSubscription]
  45    let cursor: String?
  46}
  47
  48private struct HomeInboxSubscription: Decodable, Sendable {
  49    let list: InboxMailingListReference?
  50}
  51
  52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
  53    let list: HomeInboxMailingListThreads
  54}
  55
  56private struct HomeInboxMailingListThreads: Decodable, Sendable {
  57    let threads: HomeInboxThreadPage
  58}
  59
  60private struct HomeInboxThreadPage: Decodable, Sendable {
  61    let results: [HomeInboxThreadPayload]
  62    let cursor: String?
  63}
  64
  65private struct HomeInboxThreadPayload: Decodable, Sendable {
  66    let created: Date
  67    let updated: Date
  68    let subject: String
  69    let replies: Int
  70    let sender: Entity
  71    let root: HomeInboxEmailPreview
  72}
  73
  74private struct HomeInboxEmailPreview: Decodable, Sendable {
  75    let id: Int
  76    let subject: String
  77    let date: Date?
  78    let received: Date
  79    let messageID: String
  80    let body: String
  81    let patch: HomeInboxPatchPreview?
  82}
  83
  84private struct HomeInboxPatchPreview: Decodable, Sendable {
  85    let subject: String?
  86}
  87
  88private struct HomeInboxUnreadSnapshot: Sendable {
  89    let unreadCount: Int
  90    let threads: [InboxThreadSummary]
  91}
  92
  93private struct HomeTicketPayload: Decodable, Sendable {
  94    let id: Int
  95    let title: String
  96    let status: TicketStatus
  97    let resolution: TicketResolution?
  98    let created: Date
  99    let submitter: Entity
 100    let labels: [TicketLabel]
 101    let assignees: [Entity]
 102
 103    enum CodingKeys: String, CodingKey {
 104        case id
 105        case title = "subject"
 106        case status
 107        case resolution
 108        case created
 109        case submitter
 110        case labels
 111        case assignees
 112    }
 113
 114    var ticketSummary: TicketSummary {
 115        TicketSummary(
 116            id: id,
 117            title: title,
 118            status: status,
 119            resolution: resolution,
 120            created: created,
 121            submitter: submitter,
 122            labels: labels,
 123            assignees: assignees
 124        )
 125    }
 126}
 127
 128struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
 129    let trackerId: Int
 130    let trackerRid: String
 131    let trackerName: String
 132    let ownerCanonicalName: String
 133    let ticket: TicketSummary
 134
 135    var id: String {
 136        "\(trackerRid)#\(ticket.id)"
 137    }
 138
 139    var ownerUsername: String {
 140        if ownerCanonicalName.hasPrefix("~") {
 141            return String(ownerCanonicalName.dropFirst())
 142        }
 143        return ownerCanonicalName
 144    }
 145}
 146
 147struct HomeBuildItem: Identifiable, Hashable, Sendable {
 148    let job: JobSummary
 149    let repositoryName: String?
 150    let repositoryOwner: String?
 151
 152    var id: Int { job.id }
 153
 154    var repositoryDisplayName: String? {
 155        guard let repositoryName else { return nil }
 156        if let repositoryOwner {
 157            return "\(repositoryOwner)/\(repositoryName)"
 158        }
 159        return repositoryName
 160    }
 161
 162    var requiresAttention: Bool {
 163        switch job.status {
 164        case .failed, .timeout, .running, .queued, .pending:
 165            true
 166        case .success, .cancelled:
 167            false
 168        }
 169    }
 170}
 171
 172@Observable
 173@MainActor
 174final class HomeViewModel {
 175    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    /// Loads the dashboard.
 347    ///
 348    /// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
 349    /// whatever is already cached and only schedules a background fetch, so new
 350    /// mail cannot show up on the first pull.
 351    func loadDashboard(forceRefresh: Bool = false) async {
 352        isLoadingProjects = true
 353        isLoadingAssignedTickets = true
 354        isLoadingRecentBuilds = true
 355        isLoadingSystemStatus = true
 356        projectsError = nil
 357        assignedTicketsError = nil
 358        recentBuildsError = nil
 359        isShowingStaleSystemStatus = false
 360        systemStatusErrorMessage = nil
 361
 362        async let projectsTask = loadProjects(forceRefresh: forceRefresh)
 363        async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
 364        async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
 365        async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
 366        async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
 367
 368        let projectsResult = await projectsTask
 369        switch projectsResult {
 370        case .success(let projects):
 371            self.projects = projects
 372            self.projectsError = nil
 373        case .failure(let error):
 374            self.projectsError = error.userFacingMessage
 375        }
 376        isLoadingProjects = false
 377
 378        let recentJobsResult = await jobsTask
 379
 380        switch recentJobsResult {
 381        case .success(let recentJobs):
 382            let buildItems = Self.buildItems(from: recentJobs)
 383            self.recentBuilds = buildItems
 384            self.recentBuildsError = nil
 385        case .failure(let error):
 386            self.recentBuilds = []
 387            self.recentBuildsError = error.userFacingMessage
 388        }
 389        isLoadingRecentBuilds = false
 390
 391        let assignedTicketsResult = await assignedTicketsTask
 392
 393        switch assignedTicketsResult {
 394        case .success(let assignedTickets):
 395            self.assignedTickets = assignedTickets
 396            self.assignedTicketsError = nil
 397        case .failure(let error):
 398            self.assignedTickets = []
 399            self.assignedTicketsError = error.userFacingMessage
 400        }
 401        isLoadingAssignedTickets = false
 402
 403        let inboxUnreadSnapshot = await inboxUnreadTask
 404        unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
 405        unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
 406        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 407        let systemStatusResult = await systemStatusTask
 408        switch systemStatusResult {
 409        case .success(let result):
 410            systemStatusSnapshot = result.value
 411            isShowingStaleSystemStatus = result.isStale
 412            systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
 413        case .failure(let error):
 414            systemStatusErrorMessage = error.userFacingMessage
 415        }
 416        isLoadingSystemStatus = false
 417        lastRefreshed = Date()
 418        persistNeedsAttentionSnapshot()
 419        persistSystemStatusWidgetSnapshot()
 420    }
 421
 422    /// Returns true if sufficient time has elapsed since the last dashboard refresh.
 423    func needsRefresh(after interval: TimeInterval = 60) -> Bool {
 424        guard let lastRefreshed else { return true }
 425        return Date().timeIntervalSince(lastRefreshed) > interval
 426    }
 427
 428    var hasDashboardContent: Bool {
 429        !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
 430    }
 431
 432    var pinnedProjects: [Project] {
 433        let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults)
 434        guard !pinnedIDs.isEmpty else { return [] }
 435
 436        let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
 437        return pinnedIDs.compactMap { projectsByID[$0] }
 438    }
 439
 440    var hasPinnedProjects: Bool {
 441        !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty
 442    }
 443
 444    var failedBuildCount: Int {
 445        recentFailedBuilds().count
 446    }
 447
 448    var activeBuildCount: Int {
 449        recentBuilds.filter {
 450            switch $0.job.status {
 451            case .pending, .queued, .running:
 452                return true
 453            default:
 454                return false
 455            }
 456        }.count
 457    }
 458
 459    var activeIncidentCount: Int {
 460        systemStatusSnapshot?.activeIncidents.count ?? 0
 461    }
 462
 463    var disruptedServiceCount: Int {
 464        systemStatusSnapshot?.disruptedServices.count ?? 0
 465    }
 466
 467    var needsAttentionCount: Int {
 468        var count = 0
 469        if let unreadInboxThreadCount {
 470            count += unreadInboxThreadCount
 471        }
 472        count += assignedTickets.count
 473        count += failedBuildCount
 474        count += activeBuildCount
 475        if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
 476            count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
 477        }
 478        return count
 479    }
 480
 481    var attentionSummaryText: String {
 482        if needsAttentionCount == 0 {
 483            return "All clear"
 484        }
 485        var parts: [String] = []
 486        if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
 487            parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
 488        }
 489        if !assignedTickets.isEmpty {
 490            parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
 491        }
 492        if failedBuildCount > 0 {
 493            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 494        }
 495        if activeBuildCount > 0 {
 496            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 497        }
 498        if disruptedServiceCount > 0 {
 499            parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
 500        }
 501        return parts.joined(separator: "")
 502    }
 503
 504    var inboxSummaryText: String {
 505        guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
 506        if unreadInboxThreadCount == 0 {
 507            return "Inbox zero"
 508        }
 509        return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
 510    }
 511
 512    var ticketsSummaryText: String {
 513        if assignedTickets.isEmpty {
 514            return "No open tickets assigned to you"
 515        }
 516        return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
 517    }
 518
 519    var buildsSummaryText: String {
 520        if recentBuilds.isEmpty {
 521            return "No recent builds"
 522        }
 523        var parts: [String] = []
 524        if failedBuildCount > 0 {
 525            parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
 526        }
 527        if activeBuildCount > 0 {
 528            parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
 529        }
 530        if parts.isEmpty {
 531            return "Recent builds are clear"
 532        }
 533        return parts.joined(separator: "")
 534    }
 535
 536    func recentFailedBuilds(
 537        lookbackDays: Int? = nil,
 538        now: Date = .now,
 539        calendar: Calendar = .current
 540    ) -> [HomeBuildItem] {
 541        Self.failedBuilds(
 542            in: recentBuilds,
 543            lookbackDays: lookbackDays ?? Self.failedBuildLookbackDays(),
 544            now: now,
 545            calendar: calendar
 546        )
 547    }
 548
 549    var systemSummaryText: String {
 550        guard let systemStatusSnapshot else {
 551            return systemStatusErrorMessage ?? "System status unavailable"
 552        }
 553        if systemStatusSnapshot.hasDisruption {
 554            return systemStatusSnapshot.bannerSummary
 555        }
 556        return systemStatusSnapshot.overallStatusText
 557    }
 558
 559    func resolveTicket(_ ticket: HomeAssignedTicket) async {
 560        let input: [String: any Sendable] = [
 561            "status": TicketStatus.resolved.rawValue,
 562            "resolution": TicketResolution.fixed.rawValue
 563        ]
 564        await performTicketStatusUpdate(ticket: ticket, input: input)
 565    }
 566
 567    func reopenTicket(_ ticket: HomeAssignedTicket) async {
 568        let input: [String: any Sendable] = [
 569            "status": TicketStatus.reported.rawValue
 570        ]
 571        await performTicketStatusUpdate(ticket: ticket, input: input)
 572    }
 573
 574    func unassignFromMe(_ ticket: HomeAssignedTicket) async {
 575        do {
 576            _ = try await client.execute(
 577                service: .todo,
 578                query: Self.unassignUserMutation,
 579                variables: [
 580                    "trackerId": ticket.trackerId,
 581                    "ticketId": ticket.ticket.id,
 582                    "userId": currentUser.id
 583                ],
 584                responseType: UnassignResponse.self
 585            )
 586            await invalidateHomeRelatedCaches()
 587            assignedTickets.removeAll { $0.id == ticket.id }
 588            persistNeedsAttentionSnapshot()
 589        } catch {
 590            homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
 591        }
 592    }
 593
 594    func cancelBuild(_ build: HomeBuildItem) async {
 595        guard build.job.status.isCancellable else { return }
 596
 597        do {
 598            _ = try await client.execute(
 599                service: .builds,
 600                query: Self.cancelBuildMutation,
 601                variables: ["id": build.job.id],
 602                responseType: CancelBuildResponse.self
 603            )
 604            await invalidateHomeRelatedCaches()
 605            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
 606                let updatedJob = JobSummary(
 607                    id: build.job.id,
 608                    created: build.job.created,
 609                    updated: build.job.updated,
 610                    status: .cancelled,
 611                    note: build.job.note,
 612                    tags: build.job.tags,
 613                    visibility: build.job.visibility,
 614                    image: build.job.image,
 615                    tasks: build.job.tasks
 616                )
 617                recentBuilds[index] = HomeBuildItem(
 618                    job: updatedJob,
 619                    repositoryName: build.repositoryName,
 620                    repositoryOwner: build.repositoryOwner
 621                )
 622            }
 623            persistNeedsAttentionSnapshot()
 624        } catch {
 625            homeLogger.error("Cancel build failed: \(error, privacy: .public)")
 626        }
 627    }
 628
 629    func markInboxThreadRead(_ thread: InboxThreadSummary) {
 630        InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
 631        unreadInboxThreads.removeAll { $0.id == thread.id }
 632        unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
 633        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 634        persistNeedsAttentionSnapshot()
 635    }
 636
 637    func markAllInboxThreadsRead() {
 638        guard !unreadInboxThreads.isEmpty else { return }
 639
 640        let viewedAt = Date()
 641        for thread in unreadInboxThreads {
 642            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.threadGroupingKey, defaults: defaults)
 643        }
 644
 645        unreadInboxThreads = []
 646        unreadInboxThreadCount = 0
 647        hasUnreadInboxThreads = false
 648        persistNeedsAttentionSnapshot()
 649    }
 650
 651    func markInboxThreadUnread(_ thread: InboxThreadSummary) {
 652        InboxReadStateStore.markUnread(for: thread.threadGroupingKey, defaults: defaults)
 653        if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
 654            unreadInboxThreads.append(
 655                InboxThreadSummary(
 656                    rootEmailID: thread.rootEmailID,
 657                    rootMessageID: thread.rootMessageID,
 658                    threadRootEmailIDs: thread.threadRootEmailIDs,
 659                    threadRootMessageIDs: thread.threadRootMessageIDs,
 660                    listID: thread.listID,
 661                    listRID: thread.listRID,
 662                    listName: thread.listName,
 663                    listOwner: thread.listOwner,
 664                    subject: thread.subject,
 665                    latestSender: thread.latestSender,
 666                    lastActivityAt: thread.lastActivityAt,
 667                    messageCount: thread.messageCount,
 668                    repo: thread.repo,
 669                    containsPatch: thread.containsPatch,
 670                    isUnread: true
 671                )
 672            )
 673            unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
 674        }
 675        unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
 676        hasUnreadInboxThreads = true
 677        persistNeedsAttentionSnapshot()
 678    }
 679
 680    func refreshNeedsAttentionSnapshot() {
 681        persistNeedsAttentionSnapshot()
 682    }
 683
 684    private func loadProjects(forceRefresh: Bool) async -> Result<[Project], Error> {
 685        do {
 686            return .success(try await projectService.fetchProjects())
 687        } catch {
 688            return .failure(error)
 689        }
 690    }
 691
 692    private func loadRecentJobs(forceRefresh: Bool) async -> Result<[HomeJobPayload], Error> {
 693        do {
 694            let cached = try await client.executeCached(
 695                service: .builds,
 696                query: Self.jobsQuery,
 697                responseType: HomeJobsResponse.self,
 698                cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName),
 699                resourceType: .buildList,
 700                ttl: APICacheTTLs.homeDashboard,
 701                policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
 702            )
 703            return .success(cached.value.jobs.results)
 704        } catch {
 705            return .failure(error)
 706        }
 707    }
 708
 709    private func loadInboxUnreadSnapshot(forceRefresh: Bool) async -> HomeInboxUnreadSnapshot? {
 710        do {
 711            return try await fetchUnreadInboxSnapshot(forceRefresh: forceRefresh)
 712        } catch {
 713            return nil
 714        }
 715    }
 716
 717    private func loadSystemStatusSnapshot(forceRefresh: Bool) async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
 718        do {
 719            return .success(try await systemStatusRepository.snapshotResult())
 720        } catch {
 721            return .failure(error)
 722        }
 723    }
 724
 725    private func fetchUnreadInboxSnapshot(forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
 726        let mailingLists = try await fetchInboxMailingLists(forceRefresh: forceRefresh)
 727        guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
 728
 729        var startIndex = mailingLists.startIndex
 730        var unreadCount = 0
 731        var successfulFetchCount = 0
 732        var unreadThreads: [InboxThreadSummary] = []
 733        while startIndex < mailingLists.endIndex {
 734            let endIndex = mailingLists.index(
 735                startIndex,
 736                offsetBy: inboxUnreadConcurrencyLimit,
 737                limitedBy: mailingLists.endIndex
 738            ) ?? mailingLists.endIndex
 739            let batch = Array(mailingLists[startIndex..<endIndex])
 740
 741            let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
 742                for mailingList in batch {
 743                    group.addTask {
 744                        do {
 745                            return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList, forceRefresh: forceRefresh))
 746                        } catch {
 747                            return .failure(error)
 748                        }
 749                    }
 750                }
 751
 752                var snapshots: [HomeInboxUnreadSnapshot] = []
 753                var errors: [Error] = []
 754                for await result in group {
 755                    switch result {
 756                    case .success(let snapshot):
 757                        snapshots.append(snapshot)
 758                    case .failure(let error):
 759                        errors.append(error)
 760                    }
 761                }
 762                return (snapshots, errors)
 763            }
 764
 765            unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
 766            unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
 767            successfulFetchCount += batchResult.0.count
 768
 769            startIndex = endIndex
 770        }
 771
 772        guard successfulFetchCount > 0 else {
 773            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
 774        }
 775
 776        let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads)
 777
 778        return HomeInboxUnreadSnapshot(
 779            unreadCount: deduplicatedThreads.count,
 780            threads: deduplicatedThreads
 781        )
 782    }
 783
 784    private func fetchInboxMailingLists(forceRefresh: Bool) async throws -> [InboxMailingListReference] {
 785        var subscriptions: [HomeInboxSubscription] = []
 786        var cursor: String?
 787
 788        while true {
 789            var variables: [String: any Sendable] = [:]
 790            if let cursor {
 791                variables["cursor"] = cursor
 792            }
 793
 794            let cached = try await client.executeCached(
 795                service: .lists,
 796                query: Self.inboxSubscriptionsQuery,
 797                variables: variables.isEmpty ? nil : variables,
 798                responseType: HomeInboxSubscriptionsResponse.self,
 799                cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor),
 800                resourceType: .ticketList,
 801                ttl: APICacheTTLs.inboxSummary,
 802                policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
 803            )
 804            let response = cached.value
 805
 806            subscriptions.append(contentsOf: response.subscriptions.results)
 807            guard let nextCursor = response.subscriptions.cursor else {
 808                break
 809            }
 810            cursor = nextCursor
 811        }
 812
 813        var seen = Set<String>()
 814        return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
 815    }
 816
 817    private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference, forceRefresh: Bool) async throws -> HomeInboxUnreadSnapshot {
 818        var unreadCount = 0
 819        var cursor: String?
 820        var unreadThreads: [InboxThreadSummary] = []
 821
 822        // thread.updated is the root email's insert time and never advances when a
 823        // reply lands, so activity has to come from the list's mail feed.
 824        let activity = await MailingListActivityLoader.load(
 825            client: client,
 826            listRID: mailingList.rid,
 827            since: InboxReadStateStore.baseline(defaults: defaults) ?? .distantPast
 828        )
 829
 830        while true {
 831            var variables: [String: any Sendable] = ["rid": mailingList.rid]
 832            if let cursor {
 833                variables["cursor"] = cursor
 834            }
 835
 836            let cached = try await client.executeCached(
 837                service: .lists,
 838                query: Self.inboxListThreadsQuery,
 839                variables: variables,
 840                responseType: HomeInboxListThreadsResponse.self,
 841                cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor),
 842                resourceType: .ticketList,
 843                ttl: APICacheTTLs.inboxSummary,
 844                policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
 845            )
 846            let response = cached.value
 847
 848            let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
 849                let lastActivityAt = activity.lastActivity(rootEmailID: thread.root.id, fallback: thread.updated)
 850                let summary = InboxThreadSummary(
 851                    rootEmailID: thread.root.id,
 852                    rootMessageID: thread.root.messageID,
 853                    threadRootEmailIDs: [thread.root.id],
 854                    threadRootMessageIDs: [thread.root.messageID],
 855                    listID: mailingList.id,
 856                    listRID: mailingList.rid,
 857                    listName: mailingList.name,
 858                    listOwner: mailingList.owner,
 859                    subject: thread.subject,
 860                    latestSender: thread.sender,
 861                    lastActivityAt: lastActivityAt,
 862                    messageCount: thread.replies + 1,
 863                    repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name),
 864                    containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
 865                    isUnread: InboxReadStateStore.isUnread(
 866                        threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
 867                        lastActivityAt: lastActivityAt,
 868                        defaults: defaults
 869                    )
 870                )
 871                return summary.isUnread ? summary : nil
 872            }
 873            unreadCount += unreadThreadSummaries.count
 874            unreadThreads.append(contentsOf: unreadThreadSummaries)
 875
 876            guard let nextCursor = response.list.threads.cursor else {
 877                break
 878            }
 879            cursor = nextCursor
 880        }
 881
 882        return HomeInboxUnreadSnapshot(
 883            unreadCount: unreadCount,
 884            threads: unreadThreads
 885        )
 886    }
 887
 888    private func loadAssignedTickets(forceRefresh: Bool) async -> Result<[HomeAssignedTicket], Error> {
 889        do {
 890            let trackers = try await fetchAllTrackers(forceRefresh: forceRefresh)
 891            let tickets = try await fetchAssignedTickets(for: trackers, forceRefresh: forceRefresh)
 892                .sorted(by: Self.sortAssignedTicketsForTriage)
 893            return .success(tickets)
 894        } catch {
 895            return .failure(error)
 896        }
 897    }
 898
 899    private func fetchAllTrackers(forceRefresh: Bool) async throws -> [TrackerSummary] {
 900        var allTrackers: [TrackerSummary] = []
 901        var cursor: String?
 902
 903        while true {
 904            var variables: [String: any Sendable] = [:]
 905            if let cursor {
 906                variables["cursor"] = cursor
 907            }
 908
 909            let cached = try await client.executeCached(
 910                service: .todo,
 911                query: Self.trackersQuery,
 912                variables: variables.isEmpty ? nil : variables,
 913                responseType: HomeTrackersResponse.self,
 914                cacheKey: APICacheKeys.trackers(cursor: cursor),
 915                resourceType: .ticketList,
 916                ttl: APICacheTTLs.ticketList,
 917                policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
 918            )
 919            let response = cached.value
 920
 921            allTrackers.append(contentsOf: response.trackers.results)
 922            guard let nextCursor = response.trackers.cursor else {
 923                break
 924            }
 925            cursor = nextCursor
 926        }
 927
 928        return allTrackers
 929    }
 930
 931    private func fetchAssignedTickets(for trackers: [TrackerSummary], forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
 932        guard !trackers.isEmpty else { return [] }
 933
 934        var assignedTickets: [HomeAssignedTicket] = []
 935        var startIndex = trackers.startIndex
 936
 937        while startIndex < trackers.endIndex {
 938            let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
 939            let batch = Array(trackers[startIndex..<endIndex])
 940
 941            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
 942                for tracker in batch {
 943                    group.addTask {
 944                        try await self.fetchAssignedTickets(for: tracker, forceRefresh: forceRefresh)
 945                    }
 946                }
 947
 948                var ticketsForBatch: [HomeAssignedTicket] = []
 949                for try await tickets in group {
 950                    ticketsForBatch.append(contentsOf: tickets)
 951                }
 952                return ticketsForBatch
 953            }
 954
 955            assignedTickets.append(contentsOf: batchTickets)
 956            startIndex = endIndex
 957        }
 958
 959        return assignedTickets
 960    }
 961
 962    private func fetchAssignedTickets(for tracker: TrackerSummary, forceRefresh: Bool) async throws -> [HomeAssignedTicket] {
 963        let cached = try await client.executeCached(
 964            service: .todo,
 965            query: Self.trackerTicketsQuery,
 966            variables: [
 967                "owner": tracker.owner.canonicalName.hasPrefix("~")
 968                    ? String(tracker.owner.canonicalName.dropFirst())
 969                    : tracker.owner.canonicalName,
 970                "tracker": tracker.name
 971            ],
 972            responseType: HomeTrackerTicketsResponse.self,
 973            cacheKey: APICacheKeys.homeTrackerTickets(
 974                owner: tracker.owner.canonicalName.hasPrefix("~")
 975                    ? String(tracker.owner.canonicalName.dropFirst())
 976                    : tracker.owner.canonicalName,
 977                tracker: tracker.name
 978            ),
 979            resourceType: .ticketList,
 980            ttl: APICacheTTLs.ticketList,
 981            policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
 982        )
 983        let response = cached.value
 984
 985        return response.user.tracker.tickets.results.compactMap { payload in
 986            guard payload.status.isOpen else {
 987                return nil
 988            }
 989            guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
 990                return nil
 991            }
 992
 993            return HomeAssignedTicket(
 994                trackerId: tracker.id,
 995                trackerRid: tracker.rid,
 996                trackerName: tracker.name,
 997                ownerCanonicalName: tracker.owner.canonicalName,
 998                ticket: payload.ticketSummary
 999            )
1000        }
1001    }
1002
1003    private func performTicketStatusUpdate(
1004        ticket: HomeAssignedTicket,
1005        input: [String: any Sendable]
1006    ) async {
1007        do {
1008            _ = try await client.execute(
1009                service: .todo,
1010                query: Self.updateTicketStatusMutation,
1011                variables: [
1012                    "trackerId": ticket.trackerId,
1013                    "ticketId": ticket.ticket.id,
1014                    "input": input
1015                ],
1016                responseType: StatusEventResponse.self
1017            )
1018            await invalidateHomeRelatedCaches()
1019            assignedTickets.removeAll { $0.id == ticket.id }
1020            persistNeedsAttentionSnapshot()
1021        } catch {
1022            homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
1023        }
1024    }
1025
1026    private func invalidateHomeRelatedCaches() async {
1027        await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
1028        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
1029        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
1030        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
1031        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
1032        await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
1033    }
1034
1035    private func persistNeedsAttentionSnapshot() {
1036        let failedBuildCount = recentFailedBuilds().count
1037        NeedsAttentionSnapshotStore.save(
1038            NeedsAttentionSnapshot(
1039                unreadInboxThreads: unreadInboxThreadCount,
1040                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
1041                failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
1042                updatedAt: .now
1043            ),
1044            accountID: accountID
1045        )
1046    }
1047
1048    private func persistSystemStatusWidgetSnapshot() {
1049        guard let snapshot = systemStatusSnapshot else {
1050            return
1051        }
1052        let widgetSnapshot = SystemStatusWidgetSnapshot(
1053            services: snapshot.services.map { service in
1054                SystemStatusWidgetSnapshot.ServiceEntry(
1055                    id: service.id,
1056                    name: service.name,
1057                    status: service.status.displayName,
1058                    requiresAttention: service.status.requiresAttention
1059                )
1060            },
1061            hasDisruption: snapshot.hasDisruption,
1062            overallStatusText: snapshot.overallStatusText,
1063            bannerSummary: snapshot.bannerSummary,
1064            updatedAt: .now
1065        )
1066        SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
1067    }
1068
1069    nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1070        jobs.map { job in
1071            let repository = primaryRepositoryReference(in: job.manifest)
1072            return HomeBuildItem(
1073                job: job.jobSummary,
1074                repositoryName: repository?.name,
1075                repositoryOwner: repository?.ownerCanonicalName
1076            )
1077        }
1078        .sorted(by: sortBuildItemsForTriage)
1079    }
1080
1081    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1082        buildItems(from: jobs).filter { build in
1083            switch build.job.status {
1084            case .failed, .timeout:
1085                true
1086            default:
1087                false
1088            }
1089        }
1090    }
1091
1092    nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
1093        let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
1094        guard let value, allowedFailedBuildLookbackDays.contains(value) else {
1095            return defaultFailedBuildLookbackDays
1096        }
1097        return value
1098    }
1099
1100    nonisolated static func failedBuilds(
1101        in builds: [HomeBuildItem],
1102        lookbackDays: Int,
1103        now: Date = .now,
1104        calendar: Calendar = .current
1105    ) -> [HomeBuildItem] {
1106        let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
1107            ? lookbackDays
1108            : defaultFailedBuildLookbackDays
1109        let startOfToday = calendar.startOfDay(for: now)
1110        let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
1111
1112        return builds.filter { build in
1113            guard build.job.updated >= windowStart else { return false }
1114            switch build.job.status {
1115            case .failed, .timeout:
1116                return true
1117            default:
1118                return false
1119            }
1120        }
1121    }
1122
1123    nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
1124        let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
1125            ? days
1126            : defaultFailedBuildLookbackDays
1127        if normalizedDays == 1 {
1128            return "today"
1129        }
1130        return "last \(normalizedDays) days"
1131    }
1132
1133    nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1134        let lhsPriority = buildPriority(for: lhs.job.status)
1135        let rhsPriority = buildPriority(for: rhs.job.status)
1136        if lhsPriority != rhsPriority {
1137            return lhsPriority < rhsPriority
1138        }
1139        if lhs.job.updated != rhs.job.updated {
1140            return lhs.job.updated > rhs.job.updated
1141        }
1142        return lhs.job.id > rhs.job.id
1143    }
1144
1145    nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
1146        if lhs.ticket.created != rhs.ticket.created {
1147            return lhs.ticket.created < rhs.ticket.created
1148        }
1149        return lhs.ticket.id < rhs.ticket.id
1150    }
1151
1152    nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
1153        if lhs.containsPatch != rhs.containsPatch {
1154            return lhs.containsPatch && !rhs.containsPatch
1155        }
1156        if lhs.lastActivityAt != rhs.lastActivityAt {
1157            return lhs.lastActivityAt > rhs.lastActivityAt
1158        }
1159        return InboxThreadSummary.normalizationKey(for: lhs.subject)
1160            .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1161    }
1162
1163    nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
1164        var grouped: [String: InboxThreadSummary] = [:]
1165
1166        for thread in threads {
1167            guard let existing = grouped[thread.threadGroupingKey] else {
1168                grouped[thread.threadGroupingKey] = thread
1169                continue
1170            }
1171
1172            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
1173            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
1174            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
1175            let mergedMessageCount = max(
1176                existing.messageCount ?? existing.threadRootMessageIDs.count,
1177                thread.messageCount ?? thread.threadRootMessageIDs.count,
1178                mergedRootMessageIDs.count
1179            )
1180
1181            grouped[thread.threadGroupingKey] = InboxThreadSummary(
1182                rootEmailID: latest.rootEmailID,
1183                rootMessageID: latest.rootMessageID,
1184                threadRootEmailIDs: mergedRootEmailIDs,
1185                threadRootMessageIDs: mergedRootMessageIDs,
1186                listID: latest.listID,
1187                listRID: latest.listRID,
1188                listName: latest.listName,
1189                listOwner: latest.listOwner,
1190                subject: latest.subject,
1191                latestSender: latest.latestSender,
1192                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
1193                messageCount: mergedMessageCount,
1194                repo: latest.repo ?? existing.repo,
1195                containsPatch: latest.containsPatch || existing.containsPatch,
1196                isUnread: latest.isUnread || existing.isUnread
1197            )
1198        }
1199
1200        return grouped.values.sorted(by: sortInboxThreadsForTriage)
1201    }
1202
1203    nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1204        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1205        let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1206        if assigneeCanonical == currentCanonical {
1207            return true
1208        }
1209
1210        let assigneeUsername = normalizedUsername(entity.canonicalName)
1211        let currentUsername = normalizedUsername(currentUser.username)
1212        return assigneeUsername == currentUsername
1213    }
1214
1215    nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1216        guard let manifest else { return nil }
1217        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1218        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1219            return nil
1220        }
1221        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1222        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1223              let ownerRange = Range(match.range(at: 1), in: manifest),
1224              let nameRange = Range(match.range(at: 2), in: manifest) else {
1225            return nil
1226        }
1227
1228        let owner = String(manifest[ownerRange])
1229        var name = String(manifest[nameRange])
1230        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1231            name.removeSubrange(suffixRange)
1232        }
1233        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1234        guard !name.isEmpty else { return nil }
1235        return (owner, name)
1236    }
1237
1238    private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1239        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1240        guard !trimmed.isEmpty else { return trimmed }
1241        if trimmed.hasPrefix("~") {
1242            return trimmed
1243        }
1244        return "~\(trimmed)"
1245    }
1246
1247    private nonisolated static func normalizedUsername(_ value: String) -> String {
1248        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1249        if trimmed.hasPrefix("~") {
1250            return String(trimmed.dropFirst())
1251        }
1252        return trimmed
1253    }
1254
1255    private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1256        switch status {
1257        case .failed, .timeout:
1258            return 0
1259        case .running:
1260            return 1
1261        case .queued, .pending:
1262            return 2
1263        case .cancelled:
1264            return 3
1265        case .success:
1266            return 4
1267        }
1268    }
1269
1270    private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1271        count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1272    }
1273
1274    private struct StatusEventResponse: Decodable, Sendable {
1275        struct EventRef: Decodable, Sendable {
1276            let eventType: String
1277        }
1278
1279        let updateTicketStatus: EventRef
1280    }
1281
1282    private struct UnassignResponse: Decodable, Sendable {
1283        struct EventRef: Decodable, Sendable {
1284            let id: Int
1285        }
1286
1287        let unassignUser: EventRef
1288    }
1289
1290    private struct CancelBuildResponse: Decodable, Sendable {
1291        struct CancelResult: Decodable, Sendable {
1292            let id: Int
1293        }
1294
1295        let cancel: CancelResult
1296    }
1297}
1298struct HomeJobPayload: Decodable, Sendable {
1299    let id: Int
1300    let created: Date
1301    let updated: Date
1302    let status: JobStatus
1303    let note: String?
1304    let tags: [String]
1305    let visibility: Visibility?
1306    let image: String?
1307    let tasks: [JobTaskSummary]
1308    let manifest: String?
1309
1310    nonisolated var jobSummary: JobSummary {
1311        JobSummary(
1312            id: id,
1313            created: created,
1314            updated: updated,
1315            status: status,
1316            note: note,
1317            tags: tags,
1318            visibility: visibility,
1319            image: image,
1320            tasks: tasks
1321        )
1322    }
1323}