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