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}