krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.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 assignedTickets.removeAll { $0.id == ticket.id }
582 persistNeedsAttentionSnapshot()
583 } catch {
584 homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
585 }
586 }
587
588 func cancelBuild(_ build: HomeBuildItem) async {
589 guard build.job.status.isCancellable else { return }
590
591 do {
592 _ = try await client.execute(
593 service: .builds,
594 query: Self.cancelBuildMutation,
595 variables: ["id": build.job.id],
596 responseType: CancelBuildResponse.self
597 )
598 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
599 let updatedJob = JobSummary(
600 id: build.job.id,
601 created: build.job.created,
602 updated: build.job.updated,
603 status: .cancelled,
604 note: build.job.note,
605 tags: build.job.tags,
606 visibility: build.job.visibility,
607 image: build.job.image,
608 tasks: build.job.tasks
609 )
610 recentBuilds[index] = HomeBuildItem(
611 job: updatedJob,
612 repositoryName: build.repositoryName,
613 repositoryOwner: build.repositoryOwner
614 )
615 }
616 persistNeedsAttentionSnapshot()
617 } catch {
618 homeLogger.error("Cancel build failed: \(error, privacy: .public)")
619 }
620 }
621
622 func markInboxThreadRead(_ thread: InboxThreadSummary) {
623 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
624 unreadInboxThreads.removeAll { $0.id == thread.id }
625 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
626 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
627 persistNeedsAttentionSnapshot()
628 }
629
630 func markAllInboxThreadsRead() {
631 guard !unreadInboxThreads.isEmpty else { return }
632
633 let viewedAt = Date()
634 for thread in unreadInboxThreads {
635 InboxReadStateStore.markViewed(max(viewedAt, thread.lastActivityAt), for: thread.id, defaults: defaults)
636 }
637
638 unreadInboxThreads = []
639 unreadInboxThreadCount = 0
640 hasUnreadInboxThreads = false
641 persistNeedsAttentionSnapshot()
642 }
643
644 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
645 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
646 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
647 unreadInboxThreads.append(
648 InboxThreadSummary(
649 rootEmailID: thread.rootEmailID,
650 rootMessageID: thread.rootMessageID,
651 threadRootEmailIDs: thread.threadRootEmailIDs,
652 threadRootMessageIDs: thread.threadRootMessageIDs,
653 listID: thread.listID,
654 listRID: thread.listRID,
655 listName: thread.listName,
656 listOwner: thread.listOwner,
657 subject: thread.subject,
658 latestSender: thread.latestSender,
659 lastActivityAt: thread.lastActivityAt,
660 messageCount: thread.messageCount,
661 repo: thread.repo,
662 containsPatch: thread.containsPatch,
663 isUnread: true
664 )
665 )
666 unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
667 }
668 unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
669 hasUnreadInboxThreads = true
670 persistNeedsAttentionSnapshot()
671 }
672
673 private func loadProjects() async -> Result<[Project], Error> {
674 do {
675 return .success(try await projectService.fetchProjects())
676 } catch {
677 return .failure(error)
678 }
679 }
680
681 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
682 do {
683 let response = try await client.execute(
684 service: .builds,
685 query: Self.jobsQuery,
686 responseType: HomeJobsResponse.self
687 )
688 return .success(response.jobs.results)
689 } catch {
690 return .failure(error)
691 }
692 }
693
694 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
695 do {
696 return try await fetchUnreadInboxSnapshot()
697 } catch {
698 return nil
699 }
700 }
701
702 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
703 do {
704 return .success(try await systemStatusRepository.snapshotResult())
705 } catch {
706 return .failure(error)
707 }
708 }
709
710 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
711 let mailingLists = try await fetchInboxMailingLists()
712 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
713
714 var startIndex = mailingLists.startIndex
715 var unreadCount = 0
716 var successfulFetchCount = 0
717 var unreadThreads: [InboxThreadSummary] = []
718 while startIndex < mailingLists.endIndex {
719 let endIndex = mailingLists.index(
720 startIndex,
721 offsetBy: inboxUnreadConcurrencyLimit,
722 limitedBy: mailingLists.endIndex
723 ) ?? mailingLists.endIndex
724 let batch = Array(mailingLists[startIndex..<endIndex])
725
726 let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
727 for mailingList in batch {
728 group.addTask {
729 do {
730 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
731 } catch {
732 return .failure(error)
733 }
734 }
735 }
736
737 var snapshots: [HomeInboxUnreadSnapshot] = []
738 var errors: [Error] = []
739 for await result in group {
740 switch result {
741 case .success(let snapshot):
742 snapshots.append(snapshot)
743 case .failure(let error):
744 errors.append(error)
745 }
746 }
747 return (snapshots, errors)
748 }
749
750 unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
751 unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
752 successfulFetchCount += batchResult.0.count
753
754 startIndex = endIndex
755 }
756
757 guard successfulFetchCount > 0 else {
758 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
759 }
760
761 let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads)
762
763 return HomeInboxUnreadSnapshot(
764 unreadCount: deduplicatedThreads.count,
765 threads: deduplicatedThreads
766 )
767 }
768
769 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
770 var subscriptions: [HomeInboxSubscription] = []
771 var cursor: String?
772
773 while true {
774 var variables: [String: any Sendable] = [:]
775 if let cursor {
776 variables["cursor"] = cursor
777 }
778
779 let response = try await client.execute(
780 service: .lists,
781 query: Self.inboxSubscriptionsQuery,
782 variables: variables.isEmpty ? nil : variables,
783 responseType: HomeInboxSubscriptionsResponse.self
784 )
785
786 subscriptions.append(contentsOf: response.subscriptions.results)
787 guard let nextCursor = response.subscriptions.cursor else {
788 break
789 }
790 cursor = nextCursor
791 }
792
793 var seen = Set<String>()
794 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
795 }
796
797 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
798 var unreadCount = 0
799 var cursor: String?
800 var unreadThreads: [InboxThreadSummary] = []
801
802 while true {
803 var variables: [String: any Sendable] = ["rid": mailingList.rid]
804 if let cursor {
805 variables["cursor"] = cursor
806 }
807
808 let response = try await client.execute(
809 service: .lists,
810 query: Self.inboxListThreadsQuery,
811 variables: variables,
812 responseType: HomeInboxListThreadsResponse.self
813 )
814
815 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
816 let summary = InboxThreadSummary(
817 rootEmailID: thread.root.id,
818 rootMessageID: thread.root.messageID,
819 threadRootEmailIDs: [thread.root.id],
820 threadRootMessageIDs: [thread.root.messageID],
821 listID: mailingList.id,
822 listRID: mailingList.rid,
823 listName: mailingList.name,
824 listOwner: mailingList.owner,
825 subject: thread.subject,
826 latestSender: thread.sender,
827 lastActivityAt: thread.updated,
828 messageCount: thread.replies + 1,
829 repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name),
830 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
831 isUnread: InboxReadStateStore.isUnread(
832 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
833 lastActivityAt: thread.updated,
834 defaults: defaults
835 )
836 )
837 return summary.isUnread ? summary : nil
838 }
839 unreadCount += unreadThreadSummaries.count
840 unreadThreads.append(contentsOf: unreadThreadSummaries)
841
842 guard let nextCursor = response.list.threads.cursor else {
843 break
844 }
845 cursor = nextCursor
846 }
847
848 return HomeInboxUnreadSnapshot(
849 unreadCount: unreadCount,
850 threads: unreadThreads
851 )
852 }
853
854 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
855 do {
856 let trackers = try await fetchAllTrackers()
857 let tickets = try await fetchAssignedTickets(for: trackers)
858 .sorted(by: Self.sortAssignedTicketsForTriage)
859 return .success(tickets)
860 } catch {
861 return .failure(error)
862 }
863 }
864
865 private func fetchAllTrackers() async throws -> [TrackerSummary] {
866 var allTrackers: [TrackerSummary] = []
867 var cursor: String?
868
869 while true {
870 var variables: [String: any Sendable] = [:]
871 if let cursor {
872 variables["cursor"] = cursor
873 }
874
875 let response = try await client.execute(
876 service: .todo,
877 query: Self.trackersQuery,
878 variables: variables.isEmpty ? nil : variables,
879 responseType: HomeTrackersResponse.self
880 )
881
882 allTrackers.append(contentsOf: response.trackers.results)
883 guard let nextCursor = response.trackers.cursor else {
884 break
885 }
886 cursor = nextCursor
887 }
888
889 return allTrackers
890 }
891
892 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
893 guard !trackers.isEmpty else { return [] }
894
895 var assignedTickets: [HomeAssignedTicket] = []
896 var startIndex = trackers.startIndex
897
898 while startIndex < trackers.endIndex {
899 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
900 let batch = Array(trackers[startIndex..<endIndex])
901
902 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
903 for tracker in batch {
904 group.addTask {
905 try await self.fetchAssignedTickets(for: tracker)
906 }
907 }
908
909 var ticketsForBatch: [HomeAssignedTicket] = []
910 for try await tickets in group {
911 ticketsForBatch.append(contentsOf: tickets)
912 }
913 return ticketsForBatch
914 }
915
916 assignedTickets.append(contentsOf: batchTickets)
917 startIndex = endIndex
918 }
919
920 return assignedTickets
921 }
922
923 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
924 let response = try await client.execute(
925 service: .todo,
926 query: Self.trackerTicketsQuery,
927 variables: [
928 "owner": tracker.owner.canonicalName.hasPrefix("~")
929 ? String(tracker.owner.canonicalName.dropFirst())
930 : tracker.owner.canonicalName,
931 "tracker": tracker.name
932 ],
933 responseType: HomeTrackerTicketsResponse.self
934 )
935
936 return response.user.tracker.tickets.results.compactMap { payload in
937 guard payload.status.isOpen else {
938 return nil
939 }
940 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
941 return nil
942 }
943
944 return HomeAssignedTicket(
945 trackerId: tracker.id,
946 trackerRid: tracker.rid,
947 trackerName: tracker.name,
948 ownerCanonicalName: tracker.owner.canonicalName,
949 ticket: payload.ticketSummary
950 )
951 }
952 }
953
954 private func performTicketStatusUpdate(
955 ticket: HomeAssignedTicket,
956 input: [String: any Sendable]
957 ) async {
958 do {
959 _ = try await client.execute(
960 service: .todo,
961 query: Self.updateTicketStatusMutation,
962 variables: [
963 "trackerId": ticket.trackerId,
964 "ticketId": ticket.ticket.id,
965 "input": input
966 ],
967 responseType: StatusEventResponse.self
968 )
969 assignedTickets.removeAll { $0.id == ticket.id }
970 persistNeedsAttentionSnapshot()
971 } catch {
972 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
973 }
974 }
975
976 private func persistNeedsAttentionSnapshot() {
977 let failedBuildCount = recentFailedBuilds().count
978 NeedsAttentionSnapshotStore.save(
979 NeedsAttentionSnapshot(
980 unreadInboxThreads: unreadInboxThreadCount,
981 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
982 failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
983 updatedAt: .now
984 ),
985 accountID: accountID
986 )
987 }
988
989 private func persistSystemStatusWidgetSnapshot() {
990 guard let snapshot = systemStatusSnapshot else {
991 return
992 }
993 let widgetSnapshot = SystemStatusWidgetSnapshot(
994 services: snapshot.services.map { service in
995 SystemStatusWidgetSnapshot.ServiceEntry(
996 id: service.id,
997 name: service.name,
998 status: service.status.displayName,
999 requiresAttention: service.status.requiresAttention
1000 )
1001 },
1002 hasDisruption: snapshot.hasDisruption,
1003 overallStatusText: snapshot.overallStatusText,
1004 bannerSummary: snapshot.bannerSummary,
1005 updatedAt: .now
1006 )
1007 SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
1008 }
1009
1010 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1011 jobs.map { job in
1012 let repository = primaryRepositoryReference(in: job.manifest)
1013 return HomeBuildItem(
1014 job: job.jobSummary,
1015 repositoryName: repository?.name,
1016 repositoryOwner: repository?.ownerCanonicalName
1017 )
1018 }
1019 .sorted(by: sortBuildItemsForTriage)
1020 }
1021
1022 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1023 buildItems(from: jobs).filter { build in
1024 switch build.job.status {
1025 case .failed, .timeout:
1026 true
1027 default:
1028 false
1029 }
1030 }
1031 }
1032
1033 nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
1034 let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
1035 guard let value, allowedFailedBuildLookbackDays.contains(value) else {
1036 return defaultFailedBuildLookbackDays
1037 }
1038 return value
1039 }
1040
1041 nonisolated static func failedBuilds(
1042 in builds: [HomeBuildItem],
1043 lookbackDays: Int,
1044 now: Date = .now,
1045 calendar: Calendar = .current
1046 ) -> [HomeBuildItem] {
1047 let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
1048 ? lookbackDays
1049 : defaultFailedBuildLookbackDays
1050 let startOfToday = calendar.startOfDay(for: now)
1051 let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
1052
1053 return builds.filter { build in
1054 guard build.job.updated >= windowStart else { return false }
1055 switch build.job.status {
1056 case .failed, .timeout:
1057 return true
1058 default:
1059 return false
1060 }
1061 }
1062 }
1063
1064 nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
1065 let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
1066 ? days
1067 : defaultFailedBuildLookbackDays
1068 if normalizedDays == 1 {
1069 return "today"
1070 }
1071 return "last \(normalizedDays) days"
1072 }
1073
1074 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1075 let lhsPriority = buildPriority(for: lhs.job.status)
1076 let rhsPriority = buildPriority(for: rhs.job.status)
1077 if lhsPriority != rhsPriority {
1078 return lhsPriority < rhsPriority
1079 }
1080 if lhs.job.updated != rhs.job.updated {
1081 return lhs.job.updated > rhs.job.updated
1082 }
1083 return lhs.job.id > rhs.job.id
1084 }
1085
1086 nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
1087 if lhs.ticket.created != rhs.ticket.created {
1088 return lhs.ticket.created < rhs.ticket.created
1089 }
1090 return lhs.ticket.id < rhs.ticket.id
1091 }
1092
1093 nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
1094 if lhs.containsPatch != rhs.containsPatch {
1095 return lhs.containsPatch && !rhs.containsPatch
1096 }
1097 if lhs.lastActivityAt != rhs.lastActivityAt {
1098 return lhs.lastActivityAt > rhs.lastActivityAt
1099 }
1100 return InboxThreadSummary.normalizationKey(for: lhs.subject)
1101 .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1102 }
1103
1104 nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
1105 var grouped: [String: InboxThreadSummary] = [:]
1106
1107 for thread in threads {
1108 guard let existing = grouped[thread.threadGroupingKey] else {
1109 grouped[thread.threadGroupingKey] = thread
1110 continue
1111 }
1112
1113 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
1114 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
1115 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
1116 let mergedMessageCount = max(
1117 existing.messageCount ?? existing.threadRootMessageIDs.count,
1118 thread.messageCount ?? thread.threadRootMessageIDs.count,
1119 mergedRootMessageIDs.count
1120 )
1121
1122 grouped[thread.threadGroupingKey] = InboxThreadSummary(
1123 rootEmailID: latest.rootEmailID,
1124 rootMessageID: latest.rootMessageID,
1125 threadRootEmailIDs: mergedRootEmailIDs,
1126 threadRootMessageIDs: mergedRootMessageIDs,
1127 listID: latest.listID,
1128 listRID: latest.listRID,
1129 listName: latest.listName,
1130 listOwner: latest.listOwner,
1131 subject: latest.subject,
1132 latestSender: latest.latestSender,
1133 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
1134 messageCount: mergedMessageCount,
1135 repo: latest.repo ?? existing.repo,
1136 containsPatch: latest.containsPatch || existing.containsPatch,
1137 isUnread: latest.isUnread || existing.isUnread
1138 )
1139 }
1140
1141 return grouped.values.sorted(by: sortInboxThreadsForTriage)
1142 }
1143
1144 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1145 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1146 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1147 if assigneeCanonical == currentCanonical {
1148 return true
1149 }
1150
1151 let assigneeUsername = normalizedUsername(entity.canonicalName)
1152 let currentUsername = normalizedUsername(currentUser.username)
1153 return assigneeUsername == currentUsername
1154 }
1155
1156 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1157 guard let manifest else { return nil }
1158 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1159 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1160 return nil
1161 }
1162 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1163 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1164 let ownerRange = Range(match.range(at: 1), in: manifest),
1165 let nameRange = Range(match.range(at: 2), in: manifest) else {
1166 return nil
1167 }
1168
1169 let owner = String(manifest[ownerRange])
1170 var name = String(manifest[nameRange])
1171 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1172 name.removeSubrange(suffixRange)
1173 }
1174 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1175 guard !name.isEmpty else { return nil }
1176 return (owner, name)
1177 }
1178
1179 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1180 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1181 guard !trimmed.isEmpty else { return trimmed }
1182 if trimmed.hasPrefix("~") {
1183 return trimmed
1184 }
1185 return "~\(trimmed)"
1186 }
1187
1188 private nonisolated static func normalizedUsername(_ value: String) -> String {
1189 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1190 if trimmed.hasPrefix("~") {
1191 return String(trimmed.dropFirst())
1192 }
1193 return trimmed
1194 }
1195
1196 private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1197 switch status {
1198 case .failed, .timeout:
1199 return 0
1200 case .running:
1201 return 1
1202 case .queued, .pending:
1203 return 2
1204 case .cancelled:
1205 return 3
1206 case .success:
1207 return 4
1208 }
1209 }
1210
1211 private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1212 count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1213 }
1214
1215 private struct StatusEventResponse: Decodable, Sendable {
1216 struct EventRef: Decodable, Sendable {
1217 let eventType: String
1218 }
1219
1220 let updateTicketStatus: EventRef
1221 }
1222
1223 private struct UnassignResponse: Decodable, Sendable {
1224 struct EventRef: Decodable, Sendable {
1225 let id: Int
1226 }
1227
1228 let unassignUser: EventRef
1229 }
1230
1231 private struct CancelBuildResponse: Decodable, Sendable {
1232 struct CancelResult: Decodable, Sendable {
1233 let id: Int
1234 }
1235
1236 let cancel: CancelResult
1237 }
1238}
1239struct HomeJobPayload: Decodable, Sendable {
1240 let id: Int
1241 let created: Date
1242 let updated: Date
1243 let status: JobStatus
1244 let note: String?
1245 let tags: [String]
1246 let visibility: Visibility?
1247 let image: String?
1248 let tasks: [JobTaskSummary]
1249 let manifest: String?
1250
1251 nonisolated var jobSummary: JobSummary {
1252 JobSummary(
1253 id: id,
1254 created: created,
1255 updated: updated,
1256 status: status,
1257 note: note,
1258 tags: tags,
1259 visibility: visibility,
1260 image: image,
1261 tasks: tasks
1262 )
1263 }
1264}