krz/hutch

an ios client for sourcehut

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

v3.2.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            assignedTickets.removeAll { $0.id == ticket.id }
 582            persistNeedsAttentionSnapshot()
 583        } catch {
 584            homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
 585        }
 586    }
 587
 588    func cancelBuild(_ build: HomeBuildItem) async {
 589        guard build.job.status.isCancellable else { return }
 590
 591        do {
 592            _ = try await client.execute(
 593                service: .builds,
 594                query: Self.cancelBuildMutation,
 595                variables: ["id": build.job.id],
 596                responseType: CancelBuildResponse.self
 597            )
 598            if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
 599                let updatedJob = JobSummary(
 600                    id: build.job.id,
 601                    created: build.job.created,
 602                    updated: build.job.updated,
 603                    status: .cancelled,
 604                    note: build.job.note,
 605                    tags: build.job.tags,
 606                    visibility: build.job.visibility,
 607                    image: build.job.image,
 608                    tasks: build.job.tasks
 609                )
 610                recentBuilds[index] = HomeBuildItem(
 611                    job: updatedJob,
 612                    repositoryName: build.repositoryName,
 613                    repositoryOwner: build.repositoryOwner
 614                )
 615            }
 616            persistNeedsAttentionSnapshot()
 617        } catch {
 618            homeLogger.error("Cancel build failed: \(error, privacy: .public)")
 619        }
 620    }
 621
 622    func markInboxThreadRead(_ thread: InboxThreadSummary) {
 623        InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
 624        unreadInboxThreads.removeAll { $0.id == thread.id }
 625        unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
 626        hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
 627        persistNeedsAttentionSnapshot()
 628    }
 629
 630    func markAllInboxThreadsRead() {
 631        guard !unreadInboxThreads.isEmpty else { return }
 632
 633        let viewedAt = Date()
 634        for thread in unreadInboxThreads {
 635            InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
 636        }
 637
 638        unreadInboxThreads = []
 639        unreadInboxThreadCount = 0
 640        hasUnreadInboxThreads = false
 641        persistNeedsAttentionSnapshot()
 642    }
 643
 644    func markInboxThreadUnread(_ thread: InboxThreadSummary) {
 645        InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
 646        if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
 647            unreadInboxThreads.append(
 648                InboxThreadSummary(
 649                    rootEmailID: thread.rootEmailID,
 650                    rootMessageID: thread.rootMessageID,
 651                    threadRootEmailIDs: thread.threadRootEmailIDs,
 652                    threadRootMessageIDs: thread.threadRootMessageIDs,
 653                    listID: thread.listID,
 654                    listRID: thread.listRID,
 655                    listName: thread.listName,
 656                    listOwner: thread.listOwner,
 657                    subject: thread.subject,
 658                    latestSender: thread.latestSender,
 659                    lastActivityAt: thread.lastActivityAt,
 660                    messageCount: thread.messageCount,
 661                    repo: thread.repo,
 662                    containsPatch: thread.containsPatch,
 663                    isUnread: true
 664                )
 665            )
 666            unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
 667        }
 668        unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
 669        hasUnreadInboxThreads = true
 670        persistNeedsAttentionSnapshot()
 671    }
 672
 673    func refreshNeedsAttentionSnapshot() {
 674        persistNeedsAttentionSnapshot()
 675    }
 676
 677    private func loadProjects() async -> Result<[Project], Error> {
 678        do {
 679            return .success(try await projectService.fetchProjects())
 680        } catch {
 681            return .failure(error)
 682        }
 683    }
 684
 685    private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
 686        do {
 687            let response = try await client.execute(
 688                service: .builds,
 689                query: Self.jobsQuery,
 690                responseType: HomeJobsResponse.self
 691            )
 692            return .success(response.jobs.results)
 693        } catch {
 694            return .failure(error)
 695        }
 696    }
 697
 698    private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
 699        do {
 700            return try await fetchUnreadInboxSnapshot()
 701        } catch {
 702            return nil
 703        }
 704    }
 705
 706    private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
 707        do {
 708            return .success(try await systemStatusRepository.snapshotResult())
 709        } catch {
 710            return .failure(error)
 711        }
 712    }
 713
 714    private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
 715        let mailingLists = try await fetchInboxMailingLists()
 716        guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
 717
 718        var startIndex = mailingLists.startIndex
 719        var unreadCount = 0
 720        var successfulFetchCount = 0
 721        var unreadThreads: [InboxThreadSummary] = []
 722        while startIndex < mailingLists.endIndex {
 723            let endIndex = mailingLists.index(
 724                startIndex,
 725                offsetBy: inboxUnreadConcurrencyLimit,
 726                limitedBy: mailingLists.endIndex
 727            ) ?? mailingLists.endIndex
 728            let batch = Array(mailingLists[startIndex..<endIndex])
 729
 730            let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
 731                for mailingList in batch {
 732                    group.addTask {
 733                        do {
 734                            return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
 735                        } catch {
 736                            return .failure(error)
 737                        }
 738                    }
 739                }
 740
 741                var snapshots: [HomeInboxUnreadSnapshot] = []
 742                var errors: [Error] = []
 743                for await result in group {
 744                    switch result {
 745                    case .success(let snapshot):
 746                        snapshots.append(snapshot)
 747                    case .failure(let error):
 748                        errors.append(error)
 749                    }
 750                }
 751                return (snapshots, errors)
 752            }
 753
 754            unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
 755            unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
 756            successfulFetchCount += batchResult.0.count
 757
 758            startIndex = endIndex
 759        }
 760
 761        guard successfulFetchCount > 0 else {
 762            throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
 763        }
 764
 765        let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads)
 766
 767        return HomeInboxUnreadSnapshot(
 768            unreadCount: deduplicatedThreads.count,
 769            threads: deduplicatedThreads
 770        )
 771    }
 772
 773    private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
 774        var subscriptions: [HomeInboxSubscription] = []
 775        var cursor: String?
 776
 777        while true {
 778            var variables: [String: any Sendable] = [:]
 779            if let cursor {
 780                variables["cursor"] = cursor
 781            }
 782
 783            let response = try await client.execute(
 784                service: .lists,
 785                query: Self.inboxSubscriptionsQuery,
 786                variables: variables.isEmpty ? nil : variables,
 787                responseType: HomeInboxSubscriptionsResponse.self
 788            )
 789
 790            subscriptions.append(contentsOf: response.subscriptions.results)
 791            guard let nextCursor = response.subscriptions.cursor else {
 792                break
 793            }
 794            cursor = nextCursor
 795        }
 796
 797        var seen = Set<String>()
 798        return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
 799    }
 800
 801    private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
 802        var unreadCount = 0
 803        var cursor: String?
 804        var unreadThreads: [InboxThreadSummary] = []
 805
 806        while true {
 807            var variables: [String: any Sendable] = ["rid": mailingList.rid]
 808            if let cursor {
 809                variables["cursor"] = cursor
 810            }
 811
 812            let response = try await client.execute(
 813                service: .lists,
 814                query: Self.inboxListThreadsQuery,
 815                variables: variables,
 816                responseType: HomeInboxListThreadsResponse.self
 817            )
 818
 819            let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
 820                let summary = InboxThreadSummary(
 821                    rootEmailID: thread.root.id,
 822                    rootMessageID: thread.root.messageID,
 823                    threadRootEmailIDs: [thread.root.id],
 824                    threadRootMessageIDs: [thread.root.messageID],
 825                    listID: mailingList.id,
 826                    listRID: mailingList.rid,
 827                    listName: mailingList.name,
 828                    listOwner: mailingList.owner,
 829                    subject: thread.subject,
 830                    latestSender: thread.sender,
 831                    lastActivityAt: thread.updated,
 832                    messageCount: thread.replies + 1,
 833                    repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name),
 834                    containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
 835                    isUnread: InboxReadStateStore.isUnread(
 836                        threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
 837                        lastActivityAt: thread.updated,
 838                        defaults: defaults
 839                    )
 840                )
 841                return summary.isUnread ? summary : nil
 842            }
 843            unreadCount += unreadThreadSummaries.count
 844            unreadThreads.append(contentsOf: unreadThreadSummaries)
 845
 846            guard let nextCursor = response.list.threads.cursor else {
 847                break
 848            }
 849            cursor = nextCursor
 850        }
 851
 852        return HomeInboxUnreadSnapshot(
 853            unreadCount: unreadCount,
 854            threads: unreadThreads
 855        )
 856    }
 857
 858    private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
 859        do {
 860            let trackers = try await fetchAllTrackers()
 861            let tickets = try await fetchAssignedTickets(for: trackers)
 862                .sorted(by: Self.sortAssignedTicketsForTriage)
 863            return .success(tickets)
 864        } catch {
 865            return .failure(error)
 866        }
 867    }
 868
 869    private func fetchAllTrackers() async throws -> [TrackerSummary] {
 870        var allTrackers: [TrackerSummary] = []
 871        var cursor: String?
 872
 873        while true {
 874            var variables: [String: any Sendable] = [:]
 875            if let cursor {
 876                variables["cursor"] = cursor
 877            }
 878
 879            let response = try await client.execute(
 880                service: .todo,
 881                query: Self.trackersQuery,
 882                variables: variables.isEmpty ? nil : variables,
 883                responseType: HomeTrackersResponse.self
 884            )
 885
 886            allTrackers.append(contentsOf: response.trackers.results)
 887            guard let nextCursor = response.trackers.cursor else {
 888                break
 889            }
 890            cursor = nextCursor
 891        }
 892
 893        return allTrackers
 894    }
 895
 896    private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
 897        guard !trackers.isEmpty else { return [] }
 898
 899        var assignedTickets: [HomeAssignedTicket] = []
 900        var startIndex = trackers.startIndex
 901
 902        while startIndex < trackers.endIndex {
 903            let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
 904            let batch = Array(trackers[startIndex..<endIndex])
 905
 906            let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
 907                for tracker in batch {
 908                    group.addTask {
 909                        try await self.fetchAssignedTickets(for: tracker)
 910                    }
 911                }
 912
 913                var ticketsForBatch: [HomeAssignedTicket] = []
 914                for try await tickets in group {
 915                    ticketsForBatch.append(contentsOf: tickets)
 916                }
 917                return ticketsForBatch
 918            }
 919
 920            assignedTickets.append(contentsOf: batchTickets)
 921            startIndex = endIndex
 922        }
 923
 924        return assignedTickets
 925    }
 926
 927    private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
 928        let response = try await client.execute(
 929            service: .todo,
 930            query: Self.trackerTicketsQuery,
 931            variables: [
 932                "owner": tracker.owner.canonicalName.hasPrefix("~")
 933                    ? String(tracker.owner.canonicalName.dropFirst())
 934                    : tracker.owner.canonicalName,
 935                "tracker": tracker.name
 936            ],
 937            responseType: HomeTrackerTicketsResponse.self
 938        )
 939
 940        return response.user.tracker.tickets.results.compactMap { payload in
 941            guard payload.status.isOpen else {
 942                return nil
 943            }
 944            guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
 945                return nil
 946            }
 947
 948            return HomeAssignedTicket(
 949                trackerId: tracker.id,
 950                trackerRid: tracker.rid,
 951                trackerName: tracker.name,
 952                ownerCanonicalName: tracker.owner.canonicalName,
 953                ticket: payload.ticketSummary
 954            )
 955        }
 956    }
 957
 958    private func performTicketStatusUpdate(
 959        ticket: HomeAssignedTicket,
 960        input: [String: any Sendable]
 961    ) async {
 962        do {
 963            _ = try await client.execute(
 964                service: .todo,
 965                query: Self.updateTicketStatusMutation,
 966                variables: [
 967                    "trackerId": ticket.trackerId,
 968                    "ticketId": ticket.ticket.id,
 969                    "input": input
 970                ],
 971                responseType: StatusEventResponse.self
 972            )
 973            assignedTickets.removeAll { $0.id == ticket.id }
 974            persistNeedsAttentionSnapshot()
 975        } catch {
 976            homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
 977        }
 978    }
 979
 980    private func persistNeedsAttentionSnapshot() {
 981        let failedBuildCount = recentFailedBuilds().count
 982        NeedsAttentionSnapshotStore.save(
 983            NeedsAttentionSnapshot(
 984                unreadInboxThreads: unreadInboxThreadCount,
 985                assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
 986                failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
 987                updatedAt: .now
 988            ),
 989            accountID: accountID
 990        )
 991    }
 992
 993    private func persistSystemStatusWidgetSnapshot() {
 994        guard let snapshot = systemStatusSnapshot else {
 995            return
 996        }
 997        let widgetSnapshot = SystemStatusWidgetSnapshot(
 998            services: snapshot.services.map { service in
 999                SystemStatusWidgetSnapshot.ServiceEntry(
1000                    id: service.id,
1001                    name: service.name,
1002                    status: service.status.displayName,
1003                    requiresAttention: service.status.requiresAttention
1004                )
1005            },
1006            hasDisruption: snapshot.hasDisruption,
1007            overallStatusText: snapshot.overallStatusText,
1008            bannerSummary: snapshot.bannerSummary,
1009            updatedAt: .now
1010        )
1011        SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
1012    }
1013
1014    nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1015        jobs.map { job in
1016            let repository = primaryRepositoryReference(in: job.manifest)
1017            return HomeBuildItem(
1018                job: job.jobSummary,
1019                repositoryName: repository?.name,
1020                repositoryOwner: repository?.ownerCanonicalName
1021            )
1022        }
1023        .sorted(by: sortBuildItemsForTriage)
1024    }
1025
1026    nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1027        buildItems(from: jobs).filter { build in
1028            switch build.job.status {
1029            case .failed, .timeout:
1030                true
1031            default:
1032                false
1033            }
1034        }
1035    }
1036
1037    nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
1038        let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
1039        guard let value, allowedFailedBuildLookbackDays.contains(value) else {
1040            return defaultFailedBuildLookbackDays
1041        }
1042        return value
1043    }
1044
1045    nonisolated static func failedBuilds(
1046        in builds: [HomeBuildItem],
1047        lookbackDays: Int,
1048        now: Date = .now,
1049        calendar: Calendar = .current
1050    ) -> [HomeBuildItem] {
1051        let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
1052            ? lookbackDays
1053            : defaultFailedBuildLookbackDays
1054        let startOfToday = calendar.startOfDay(for: now)
1055        let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
1056
1057        return builds.filter { build in
1058            guard build.job.updated >= windowStart else { return false }
1059            switch build.job.status {
1060            case .failed, .timeout:
1061                return true
1062            default:
1063                return false
1064            }
1065        }
1066    }
1067
1068    nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
1069        let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
1070            ? days
1071            : defaultFailedBuildLookbackDays
1072        if normalizedDays == 1 {
1073            return "today"
1074        }
1075        return "last \(normalizedDays) days"
1076    }
1077
1078    nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1079        let lhsPriority = buildPriority(for: lhs.job.status)
1080        let rhsPriority = buildPriority(for: rhs.job.status)
1081        if lhsPriority != rhsPriority {
1082            return lhsPriority < rhsPriority
1083        }
1084        if lhs.job.updated != rhs.job.updated {
1085            return lhs.job.updated > rhs.job.updated
1086        }
1087        return lhs.job.id > rhs.job.id
1088    }
1089
1090    nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
1091        if lhs.ticket.created != rhs.ticket.created {
1092            return lhs.ticket.created < rhs.ticket.created
1093        }
1094        return lhs.ticket.id < rhs.ticket.id
1095    }
1096
1097    nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
1098        if lhs.containsPatch != rhs.containsPatch {
1099            return lhs.containsPatch && !rhs.containsPatch
1100        }
1101        if lhs.lastActivityAt != rhs.lastActivityAt {
1102            return lhs.lastActivityAt > rhs.lastActivityAt
1103        }
1104        return InboxThreadSummary.normalizationKey(for: lhs.subject)
1105            .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1106    }
1107
1108    nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
1109        var grouped: [String: InboxThreadSummary] = [:]
1110
1111        for thread in threads {
1112            guard let existing = grouped[thread.threadGroupingKey] else {
1113                grouped[thread.threadGroupingKey] = thread
1114                continue
1115            }
1116
1117            let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
1118            let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
1119            let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
1120            let mergedMessageCount = max(
1121                existing.messageCount ?? existing.threadRootMessageIDs.count,
1122                thread.messageCount ?? thread.threadRootMessageIDs.count,
1123                mergedRootMessageIDs.count
1124            )
1125
1126            grouped[thread.threadGroupingKey] = InboxThreadSummary(
1127                rootEmailID: latest.rootEmailID,
1128                rootMessageID: latest.rootMessageID,
1129                threadRootEmailIDs: mergedRootEmailIDs,
1130                threadRootMessageIDs: mergedRootMessageIDs,
1131                listID: latest.listID,
1132                listRID: latest.listRID,
1133                listName: latest.listName,
1134                listOwner: latest.listOwner,
1135                subject: latest.subject,
1136                latestSender: latest.latestSender,
1137                lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
1138                messageCount: mergedMessageCount,
1139                repo: latest.repo ?? existing.repo,
1140                containsPatch: latest.containsPatch || existing.containsPatch,
1141                isUnread: latest.isUnread || existing.isUnread
1142            )
1143        }
1144
1145        return grouped.values.sorted(by: sortInboxThreadsForTriage)
1146    }
1147
1148    nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1149        let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1150        let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1151        if assigneeCanonical == currentCanonical {
1152            return true
1153        }
1154
1155        let assigneeUsername = normalizedUsername(entity.canonicalName)
1156        let currentUsername = normalizedUsername(currentUser.username)
1157        return assigneeUsername == currentUsername
1158    }
1159
1160    nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1161        guard let manifest else { return nil }
1162        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1163        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1164            return nil
1165        }
1166        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1167        guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1168              let ownerRange = Range(match.range(at: 1), in: manifest),
1169              let nameRange = Range(match.range(at: 2), in: manifest) else {
1170            return nil
1171        }
1172
1173        let owner = String(manifest[ownerRange])
1174        var name = String(manifest[nameRange])
1175        if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1176            name.removeSubrange(suffixRange)
1177        }
1178        name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1179        guard !name.isEmpty else { return nil }
1180        return (owner, name)
1181    }
1182
1183    private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1184        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1185        guard !trimmed.isEmpty else { return trimmed }
1186        if trimmed.hasPrefix("~") {
1187            return trimmed
1188        }
1189        return "~\(trimmed)"
1190    }
1191
1192    private nonisolated static func normalizedUsername(_ value: String) -> String {
1193        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1194        if trimmed.hasPrefix("~") {
1195            return String(trimmed.dropFirst())
1196        }
1197        return trimmed
1198    }
1199
1200    private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1201        switch status {
1202        case .failed, .timeout:
1203            return 0
1204        case .running:
1205            return 1
1206        case .queued, .pending:
1207            return 2
1208        case .cancelled:
1209            return 3
1210        case .success:
1211            return 4
1212        }
1213    }
1214
1215    private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1216        count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1217    }
1218
1219    private struct StatusEventResponse: Decodable, Sendable {
1220        struct EventRef: Decodable, Sendable {
1221            let eventType: String
1222        }
1223
1224        let updateTicketStatus: EventRef
1225    }
1226
1227    private struct UnassignResponse: Decodable, Sendable {
1228        struct EventRef: Decodable, Sendable {
1229            let id: Int
1230        }
1231
1232        let unassignUser: EventRef
1233    }
1234
1235    private struct CancelBuildResponse: Decodable, Sendable {
1236        struct CancelResult: Decodable, Sendable {
1237            let id: Int
1238        }
1239
1240        let cancel: CancelResult
1241    }
1242}
1243struct HomeJobPayload: Decodable, Sendable {
1244    let id: Int
1245    let created: Date
1246    let updated: Date
1247    let status: JobStatus
1248    let note: String?
1249    let tags: [String]
1250    let visibility: Visibility?
1251    let image: String?
1252    let tasks: [JobTaskSummary]
1253    let manifest: String?
1254
1255    nonisolated var jobSummary: JobSummary {
1256        JobSummary(
1257            id: id,
1258            created: created,
1259            updated: updated,
1260            status: status,
1261            note: note,
1262            tags: tags,
1263            visibility: visibility,
1264            image: image,
1265            tasks: tasks
1266        )
1267    }
1268}