krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.9.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}