krz/hutch

an ios client for sourcehut

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

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