krz/hutch

an ios client for sourcehut

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

remove-splash-highlighter: Hutch/Views/Home/HomeViewModel.swift · raw

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