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