krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.2.1: Hutch/Views/Home/HomeViewModel.swift · raw
1import Foundation
2import os
3
4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
5
6private struct HomeJobsResponse: Decodable, Sendable {
7 let jobs: HomeJobsPage
8}
9
10private struct HomeJobsPage: Decodable, Sendable {
11 let results: [HomeJobPayload]
12}
13
14private struct HomeTrackersResponse: Decodable, Sendable {
15 let trackers: HomeTrackersPage
16}
17
18private struct HomeTrackersPage: Decodable, Sendable {
19 let results: [TrackerSummary]
20 let cursor: String?
21}
22
23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
24 let user: HomeTrackerTicketsUser
25}
26
27private struct HomeTrackerTicketsUser: Decodable, Sendable {
28 let tracker: HomeTrackerTicketsTracker
29}
30
31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
32 let tickets: HomeTrackerTicketsPage
33}
34
35private struct HomeTrackerTicketsPage: Decodable, Sendable {
36 let results: [HomeTicketPayload]
37}
38
39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
40 let subscriptions: HomeInboxSubscriptionPage
41}
42
43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
44 let results: [HomeInboxSubscription]
45 let cursor: String?
46}
47
48private struct HomeInboxSubscription: Decodable, Sendable {
49 let list: InboxMailingListReference?
50}
51
52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
53 let list: HomeInboxMailingListThreads
54}
55
56private struct HomeInboxMailingListThreads: Decodable, Sendable {
57 let threads: HomeInboxThreadPage
58}
59
60private struct HomeInboxThreadPage: Decodable, Sendable {
61 let results: [HomeInboxThreadPayload]
62 let cursor: String?
63}
64
65private struct HomeInboxThreadPayload: Decodable, Sendable {
66 let created: Date
67 let updated: Date
68 let subject: String
69 let replies: Int
70 let sender: Entity
71 let root: HomeInboxEmailPreview
72}
73
74private struct HomeInboxEmailPreview: Decodable, Sendable {
75 let id: Int
76 let subject: String
77 let date: Date?
78 let received: Date
79 let messageID: String
80 let body: String
81 let patch: HomeInboxPatchPreview?
82}
83
84private struct HomeInboxPatchPreview: Decodable, Sendable {
85 let subject: String?
86}
87
88private struct HomeInboxUnreadSnapshot: Sendable {
89 let unreadCount: Int
90 let threads: [InboxThreadSummary]
91}
92
93private struct HomeTicketPayload: Decodable, Sendable {
94 let id: Int
95 let title: String
96 let status: TicketStatus
97 let resolution: TicketResolution?
98 let created: Date
99 let submitter: Entity
100 let labels: [TicketLabel]
101 let assignees: [Entity]
102
103 enum CodingKeys: String, CodingKey {
104 case id
105 case title = "subject"
106 case status
107 case resolution
108 case created
109 case submitter
110 case labels
111 case assignees
112 }
113
114 var ticketSummary: TicketSummary {
115 TicketSummary(
116 id: id,
117 title: title,
118 status: status,
119 resolution: resolution,
120 created: created,
121 submitter: submitter,
122 labels: labels,
123 assignees: assignees
124 )
125 }
126}
127
128struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
129 let trackerId: Int
130 let trackerRid: String
131 let trackerName: String
132 let ownerCanonicalName: String
133 let ticket: TicketSummary
134
135 var id: String {
136 "\(trackerRid)#\(ticket.id)"
137 }
138
139 var ownerUsername: String {
140 if ownerCanonicalName.hasPrefix("~") {
141 return String(ownerCanonicalName.dropFirst())
142 }
143 return ownerCanonicalName
144 }
145}
146
147struct HomeBuildItem: Identifiable, Hashable, Sendable {
148 let job: JobSummary
149 let repositoryName: String?
150 let repositoryOwner: String?
151
152 var id: Int { job.id }
153
154 var repositoryDisplayName: String? {
155 guard let repositoryName else { return nil }
156 if let repositoryOwner {
157 return "\(repositoryOwner)/\(repositoryName)"
158 }
159 return repositoryName
160 }
161
162 var requiresAttention: Bool {
163 switch job.status {
164 case .failed, .timeout, .running, .queued, .pending:
165 true
166 case .success, .cancelled:
167 false
168 }
169 }
170}
171
172@Observable
173@MainActor
174final class HomeViewModel {
175 nonisolated static let defaultFailedBuildLookbackDays = 7
176 nonisolated static let allowedFailedBuildLookbackDays = [1, 3, 7, 14, 30]
177
178 private(set) var projects: [Project] = []
179 var assignedTickets: [HomeAssignedTicket] = []
180 var recentBuilds: [HomeBuildItem] = []
181 var unreadInboxThreads: [InboxThreadSummary] = []
182 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
183 private(set) var isLoadingSystemStatus = false
184 private(set) var isShowingStaleSystemStatus = false
185 private(set) var systemStatusErrorMessage: String?
186 private(set) var hasUnreadInboxThreads = false
187 private(set) var unreadInboxThreadCount: Int?
188 private(set) var isLoadingProjects = false
189 private(set) var isLoadingAssignedTickets = false
190 private(set) var isLoadingRecentBuilds = false
191 private(set) var projectsError: String?
192 private(set) var assignedTicketsError: String?
193 private(set) var recentBuildsError: String?
194 private(set) var lastRefreshed: Date?
195
196 private let currentUser: User
197 private let client: SRHTClient
198 private let systemStatusRepository: SystemStatusRepository
199 private let projectService: ProjectService
200 private let ticketFetchConcurrencyLimit = 6
201 private let inboxUnreadConcurrencyLimit = 4
202
203 private var currentUserKey: String {
204 currentUser.canonicalName
205 }
206
207 private static let jobsQuery = """
208 query jobs {
209 jobs {
210 results {
211 id
212 created
213 updated
214 status
215 note
216 tags
217 visibility
218 image
219 tasks { name status }
220 manifest
221 }
222 }
223 }
224 """
225
226 private static let trackersQuery = """
227 query trackers($cursor: Cursor) {
228 trackers(cursor: $cursor) {
229 results {
230 id
231 rid
232 name
233 description
234 visibility
235 updated
236 owner { canonicalName }
237 }
238 cursor
239 }
240 }
241 """
242
243 private static let trackerTicketsQuery = """
244 query tickets($owner: String!, $tracker: String!) {
245 user(username: $owner) {
246 tracker(name: $tracker) {
247 tickets {
248 results {
249 id
250 subject
251 status
252 resolution
253 created
254 submitter { canonicalName }
255 labels { id name backgroundColor foregroundColor }
256 assignees { canonicalName }
257 }
258 }
259 }
260 }
261 }
262 """
263
264 private static let inboxSubscriptionsQuery = """
265 query inboxSubscriptions($cursor: Cursor) {
266 subscriptions(cursor: $cursor) {
267 results {
268 ... on MailingListSubscription {
269 list {
270 id
271 rid
272 name
273 owner { canonicalName }
274 }
275 }
276 }
277 cursor
278 }
279 }
280 """
281
282 private static let inboxListThreadsQuery = """
283 query inboxListThreads($rid: ID!, $cursor: Cursor) {
284 list(rid: $rid) {
285 threads(cursor: $cursor) {
286 results {
287 created
288 updated
289 subject
290 replies
291 sender { canonicalName }
292 root {
293 id
294 subject
295 date
296 received
297 messageID
298 body
299 patch { subject }
300 }
301 }
302 cursor
303 }
304 }
305 }
306 """
307
308 private static let updateTicketStatusMutation = """
309 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
310 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
311 eventType: __typename
312 }
313 }
314 """
315
316 private static let unassignUserMutation = """
317 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
318 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
319 }
320 """
321
322 private static let cancelBuildMutation = """
323 mutation cancel($id: Int!) {
324 cancel(jobId: $id) { id }
325 }
326 """
327
328 private let defaults: UserDefaults
329 private let accountID: String
330
331 init(
332 currentUser: User,
333 client: SRHTClient,
334 systemStatusRepository: SystemStatusRepository,
335 defaults: UserDefaults,
336 accountID: String
337 ) {
338 self.currentUser = currentUser
339 self.client = client
340 self.systemStatusRepository = systemStatusRepository
341 self.projectService = ProjectService(client: client)
342 self.defaults = defaults
343 self.accountID = accountID
344 }
345
346 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 func refreshNeedsAttentionSnapshot() {
674 persistNeedsAttentionSnapshot()
675 }
676
677 private func loadProjects() async -> Result<[Project], Error> {
678 do {
679 return .success(try await projectService.fetchProjects())
680 } catch {
681 return .failure(error)
682 }
683 }
684
685 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
686 do {
687 let response = try await client.execute(
688 service: .builds,
689 query: Self.jobsQuery,
690 responseType: HomeJobsResponse.self
691 )
692 return .success(response.jobs.results)
693 } catch {
694 return .failure(error)
695 }
696 }
697
698 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
699 do {
700 return try await fetchUnreadInboxSnapshot()
701 } catch {
702 return nil
703 }
704 }
705
706 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
707 do {
708 return .success(try await systemStatusRepository.snapshotResult())
709 } catch {
710 return .failure(error)
711 }
712 }
713
714 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
715 let mailingLists = try await fetchInboxMailingLists()
716 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
717
718 var startIndex = mailingLists.startIndex
719 var unreadCount = 0
720 var successfulFetchCount = 0
721 var unreadThreads: [InboxThreadSummary] = []
722 while startIndex < mailingLists.endIndex {
723 let endIndex = mailingLists.index(
724 startIndex,
725 offsetBy: inboxUnreadConcurrencyLimit,
726 limitedBy: mailingLists.endIndex
727 ) ?? mailingLists.endIndex
728 let batch = Array(mailingLists[startIndex..<endIndex])
729
730 let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
731 for mailingList in batch {
732 group.addTask {
733 do {
734 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
735 } catch {
736 return .failure(error)
737 }
738 }
739 }
740
741 var snapshots: [HomeInboxUnreadSnapshot] = []
742 var errors: [Error] = []
743 for await result in group {
744 switch result {
745 case .success(let snapshot):
746 snapshots.append(snapshot)
747 case .failure(let error):
748 errors.append(error)
749 }
750 }
751 return (snapshots, errors)
752 }
753
754 unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
755 unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
756 successfulFetchCount += batchResult.0.count
757
758 startIndex = endIndex
759 }
760
761 guard successfulFetchCount > 0 else {
762 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
763 }
764
765 let deduplicatedThreads = Self.deduplicateInboxThreads(unreadThreads)
766
767 return HomeInboxUnreadSnapshot(
768 unreadCount: deduplicatedThreads.count,
769 threads: deduplicatedThreads
770 )
771 }
772
773 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
774 var subscriptions: [HomeInboxSubscription] = []
775 var cursor: String?
776
777 while true {
778 var variables: [String: any Sendable] = [:]
779 if let cursor {
780 variables["cursor"] = cursor
781 }
782
783 let response = try await client.execute(
784 service: .lists,
785 query: Self.inboxSubscriptionsQuery,
786 variables: variables.isEmpty ? nil : variables,
787 responseType: HomeInboxSubscriptionsResponse.self
788 )
789
790 subscriptions.append(contentsOf: response.subscriptions.results)
791 guard let nextCursor = response.subscriptions.cursor else {
792 break
793 }
794 cursor = nextCursor
795 }
796
797 var seen = Set<String>()
798 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
799 }
800
801 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
802 var unreadCount = 0
803 var cursor: String?
804 var unreadThreads: [InboxThreadSummary] = []
805
806 while true {
807 var variables: [String: any Sendable] = ["rid": mailingList.rid]
808 if let cursor {
809 variables["cursor"] = cursor
810 }
811
812 let response = try await client.execute(
813 service: .lists,
814 query: Self.inboxListThreadsQuery,
815 variables: variables,
816 responseType: HomeInboxListThreadsResponse.self
817 )
818
819 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
820 let summary = InboxThreadSummary(
821 rootEmailID: thread.root.id,
822 rootMessageID: thread.root.messageID,
823 threadRootEmailIDs: [thread.root.id],
824 threadRootMessageIDs: [thread.root.messageID],
825 listID: mailingList.id,
826 listRID: mailingList.rid,
827 listName: mailingList.name,
828 listOwner: mailingList.owner,
829 subject: thread.subject,
830 latestSender: thread.sender,
831 lastActivityAt: thread.updated,
832 messageCount: thread.replies + 1,
833 repo: InboxThreadUtilities.deriveRepositoryName(from: mailingList.name),
834 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
835 isUnread: InboxReadStateStore.isUnread(
836 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
837 lastActivityAt: thread.updated,
838 defaults: defaults
839 )
840 )
841 return summary.isUnread ? summary : nil
842 }
843 unreadCount += unreadThreadSummaries.count
844 unreadThreads.append(contentsOf: unreadThreadSummaries)
845
846 guard let nextCursor = response.list.threads.cursor else {
847 break
848 }
849 cursor = nextCursor
850 }
851
852 return HomeInboxUnreadSnapshot(
853 unreadCount: unreadCount,
854 threads: unreadThreads
855 )
856 }
857
858 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
859 do {
860 let trackers = try await fetchAllTrackers()
861 let tickets = try await fetchAssignedTickets(for: trackers)
862 .sorted(by: Self.sortAssignedTicketsForTriage)
863 return .success(tickets)
864 } catch {
865 return .failure(error)
866 }
867 }
868
869 private func fetchAllTrackers() async throws -> [TrackerSummary] {
870 var allTrackers: [TrackerSummary] = []
871 var cursor: String?
872
873 while true {
874 var variables: [String: any Sendable] = [:]
875 if let cursor {
876 variables["cursor"] = cursor
877 }
878
879 let response = try await client.execute(
880 service: .todo,
881 query: Self.trackersQuery,
882 variables: variables.isEmpty ? nil : variables,
883 responseType: HomeTrackersResponse.self
884 )
885
886 allTrackers.append(contentsOf: response.trackers.results)
887 guard let nextCursor = response.trackers.cursor else {
888 break
889 }
890 cursor = nextCursor
891 }
892
893 return allTrackers
894 }
895
896 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
897 guard !trackers.isEmpty else { return [] }
898
899 var assignedTickets: [HomeAssignedTicket] = []
900 var startIndex = trackers.startIndex
901
902 while startIndex < trackers.endIndex {
903 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
904 let batch = Array(trackers[startIndex..<endIndex])
905
906 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
907 for tracker in batch {
908 group.addTask {
909 try await self.fetchAssignedTickets(for: tracker)
910 }
911 }
912
913 var ticketsForBatch: [HomeAssignedTicket] = []
914 for try await tickets in group {
915 ticketsForBatch.append(contentsOf: tickets)
916 }
917 return ticketsForBatch
918 }
919
920 assignedTickets.append(contentsOf: batchTickets)
921 startIndex = endIndex
922 }
923
924 return assignedTickets
925 }
926
927 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
928 let response = try await client.execute(
929 service: .todo,
930 query: Self.trackerTicketsQuery,
931 variables: [
932 "owner": tracker.owner.canonicalName.hasPrefix("~")
933 ? String(tracker.owner.canonicalName.dropFirst())
934 : tracker.owner.canonicalName,
935 "tracker": tracker.name
936 ],
937 responseType: HomeTrackerTicketsResponse.self
938 )
939
940 return response.user.tracker.tickets.results.compactMap { payload in
941 guard payload.status.isOpen else {
942 return nil
943 }
944 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
945 return nil
946 }
947
948 return HomeAssignedTicket(
949 trackerId: tracker.id,
950 trackerRid: tracker.rid,
951 trackerName: tracker.name,
952 ownerCanonicalName: tracker.owner.canonicalName,
953 ticket: payload.ticketSummary
954 )
955 }
956 }
957
958 private func performTicketStatusUpdate(
959 ticket: HomeAssignedTicket,
960 input: [String: any Sendable]
961 ) async {
962 do {
963 _ = try await client.execute(
964 service: .todo,
965 query: Self.updateTicketStatusMutation,
966 variables: [
967 "trackerId": ticket.trackerId,
968 "ticketId": ticket.ticket.id,
969 "input": input
970 ],
971 responseType: StatusEventResponse.self
972 )
973 assignedTickets.removeAll { $0.id == ticket.id }
974 persistNeedsAttentionSnapshot()
975 } catch {
976 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
977 }
978 }
979
980 private func persistNeedsAttentionSnapshot() {
981 let failedBuildCount = recentFailedBuilds().count
982 NeedsAttentionSnapshotStore.save(
983 NeedsAttentionSnapshot(
984 unreadInboxThreads: unreadInboxThreadCount,
985 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
986 failedBuilds: recentBuildsError == nil ? failedBuildCount : nil,
987 updatedAt: .now
988 ),
989 accountID: accountID
990 )
991 }
992
993 private func persistSystemStatusWidgetSnapshot() {
994 guard let snapshot = systemStatusSnapshot else {
995 return
996 }
997 let widgetSnapshot = SystemStatusWidgetSnapshot(
998 services: snapshot.services.map { service in
999 SystemStatusWidgetSnapshot.ServiceEntry(
1000 id: service.id,
1001 name: service.name,
1002 status: service.status.displayName,
1003 requiresAttention: service.status.requiresAttention
1004 )
1005 },
1006 hasDisruption: snapshot.hasDisruption,
1007 overallStatusText: snapshot.overallStatusText,
1008 bannerSummary: snapshot.bannerSummary,
1009 updatedAt: .now
1010 )
1011 SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
1012 }
1013
1014 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1015 jobs.map { job in
1016 let repository = primaryRepositoryReference(in: job.manifest)
1017 return HomeBuildItem(
1018 job: job.jobSummary,
1019 repositoryName: repository?.name,
1020 repositoryOwner: repository?.ownerCanonicalName
1021 )
1022 }
1023 .sorted(by: sortBuildItemsForTriage)
1024 }
1025
1026 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1027 buildItems(from: jobs).filter { build in
1028 switch build.job.status {
1029 case .failed, .timeout:
1030 true
1031 default:
1032 false
1033 }
1034 }
1035 }
1036
1037 nonisolated static func failedBuildLookbackDays(defaults: UserDefaults = .standard) -> Int {
1038 let value = defaults.object(forKey: AppStorageKeys.homeFailedBuildLookbackDays) as? Int
1039 guard let value, allowedFailedBuildLookbackDays.contains(value) else {
1040 return defaultFailedBuildLookbackDays
1041 }
1042 return value
1043 }
1044
1045 nonisolated static func failedBuilds(
1046 in builds: [HomeBuildItem],
1047 lookbackDays: Int,
1048 now: Date = .now,
1049 calendar: Calendar = .current
1050 ) -> [HomeBuildItem] {
1051 let normalizedLookbackDays = allowedFailedBuildLookbackDays.contains(lookbackDays)
1052 ? lookbackDays
1053 : defaultFailedBuildLookbackDays
1054 let startOfToday = calendar.startOfDay(for: now)
1055 let windowStart = calendar.date(byAdding: .day, value: -(normalizedLookbackDays - 1), to: startOfToday) ?? startOfToday
1056
1057 return builds.filter { build in
1058 guard build.job.updated >= windowStart else { return false }
1059 switch build.job.status {
1060 case .failed, .timeout:
1061 return true
1062 default:
1063 return false
1064 }
1065 }
1066 }
1067
1068 nonisolated static func failedBuildLookbackLabel(days: Int) -> String {
1069 let normalizedDays = allowedFailedBuildLookbackDays.contains(days)
1070 ? days
1071 : defaultFailedBuildLookbackDays
1072 if normalizedDays == 1 {
1073 return "today"
1074 }
1075 return "last \(normalizedDays) days"
1076 }
1077
1078 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1079 let lhsPriority = buildPriority(for: lhs.job.status)
1080 let rhsPriority = buildPriority(for: rhs.job.status)
1081 if lhsPriority != rhsPriority {
1082 return lhsPriority < rhsPriority
1083 }
1084 if lhs.job.updated != rhs.job.updated {
1085 return lhs.job.updated > rhs.job.updated
1086 }
1087 return lhs.job.id > rhs.job.id
1088 }
1089
1090 nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
1091 if lhs.ticket.created != rhs.ticket.created {
1092 return lhs.ticket.created < rhs.ticket.created
1093 }
1094 return lhs.ticket.id < rhs.ticket.id
1095 }
1096
1097 nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
1098 if lhs.containsPatch != rhs.containsPatch {
1099 return lhs.containsPatch && !rhs.containsPatch
1100 }
1101 if lhs.lastActivityAt != rhs.lastActivityAt {
1102 return lhs.lastActivityAt > rhs.lastActivityAt
1103 }
1104 return InboxThreadSummary.normalizationKey(for: lhs.subject)
1105 .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1106 }
1107
1108 nonisolated static func deduplicateInboxThreads(_ threads: [InboxThreadSummary]) -> [InboxThreadSummary] {
1109 var grouped: [String: InboxThreadSummary] = [:]
1110
1111 for thread in threads {
1112 guard let existing = grouped[thread.threadGroupingKey] else {
1113 grouped[thread.threadGroupingKey] = thread
1114 continue
1115 }
1116
1117 let latest = thread.lastActivityAt >= existing.lastActivityAt ? thread : existing
1118 let mergedRootEmailIDs = Array(Set(existing.threadRootEmailIDs + thread.threadRootEmailIDs)).sorted()
1119 let mergedRootMessageIDs = Array(Set(existing.threadRootMessageIDs + thread.threadRootMessageIDs)).sorted()
1120 let mergedMessageCount = max(
1121 existing.messageCount ?? existing.threadRootMessageIDs.count,
1122 thread.messageCount ?? thread.threadRootMessageIDs.count,
1123 mergedRootMessageIDs.count
1124 )
1125
1126 grouped[thread.threadGroupingKey] = InboxThreadSummary(
1127 rootEmailID: latest.rootEmailID,
1128 rootMessageID: latest.rootMessageID,
1129 threadRootEmailIDs: mergedRootEmailIDs,
1130 threadRootMessageIDs: mergedRootMessageIDs,
1131 listID: latest.listID,
1132 listRID: latest.listRID,
1133 listName: latest.listName,
1134 listOwner: latest.listOwner,
1135 subject: latest.subject,
1136 latestSender: latest.latestSender,
1137 lastActivityAt: max(existing.lastActivityAt, thread.lastActivityAt),
1138 messageCount: mergedMessageCount,
1139 repo: latest.repo ?? existing.repo,
1140 containsPatch: latest.containsPatch || existing.containsPatch,
1141 isUnread: latest.isUnread || existing.isUnread
1142 )
1143 }
1144
1145 return grouped.values.sorted(by: sortInboxThreadsForTriage)
1146 }
1147
1148 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1149 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1150 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1151 if assigneeCanonical == currentCanonical {
1152 return true
1153 }
1154
1155 let assigneeUsername = normalizedUsername(entity.canonicalName)
1156 let currentUsername = normalizedUsername(currentUser.username)
1157 return assigneeUsername == currentUsername
1158 }
1159
1160 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1161 guard let manifest else { return nil }
1162 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1163 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1164 return nil
1165 }
1166 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1167 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1168 let ownerRange = Range(match.range(at: 1), in: manifest),
1169 let nameRange = Range(match.range(at: 2), in: manifest) else {
1170 return nil
1171 }
1172
1173 let owner = String(manifest[ownerRange])
1174 var name = String(manifest[nameRange])
1175 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1176 name.removeSubrange(suffixRange)
1177 }
1178 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1179 guard !name.isEmpty else { return nil }
1180 return (owner, name)
1181 }
1182
1183 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1184 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1185 guard !trimmed.isEmpty else { return trimmed }
1186 if trimmed.hasPrefix("~") {
1187 return trimmed
1188 }
1189 return "~\(trimmed)"
1190 }
1191
1192 private nonisolated static func normalizedUsername(_ value: String) -> String {
1193 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1194 if trimmed.hasPrefix("~") {
1195 return String(trimmed.dropFirst())
1196 }
1197 return trimmed
1198 }
1199
1200 private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1201 switch status {
1202 case .failed, .timeout:
1203 return 0
1204 case .running:
1205 return 1
1206 case .queued, .pending:
1207 return 2
1208 case .cancelled:
1209 return 3
1210 case .success:
1211 return 4
1212 }
1213 }
1214
1215 private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1216 count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1217 }
1218
1219 private struct StatusEventResponse: Decodable, Sendable {
1220 struct EventRef: Decodable, Sendable {
1221 let eventType: String
1222 }
1223
1224 let updateTicketStatus: EventRef
1225 }
1226
1227 private struct UnassignResponse: Decodable, Sendable {
1228 struct EventRef: Decodable, Sendable {
1229 let id: Int
1230 }
1231
1232 let unassignUser: EventRef
1233 }
1234
1235 private struct CancelBuildResponse: Decodable, Sendable {
1236 struct CancelResult: Decodable, Sendable {
1237 let id: Int
1238 }
1239
1240 let cancel: CancelResult
1241 }
1242}
1243struct HomeJobPayload: Decodable, Sendable {
1244 let id: Int
1245 let created: Date
1246 let updated: Date
1247 let status: JobStatus
1248 let note: String?
1249 let tags: [String]
1250 let visibility: Visibility?
1251 let image: String?
1252 let tasks: [JobTaskSummary]
1253 let manifest: String?
1254
1255 nonisolated var jobSummary: JobSummary {
1256 JobSummary(
1257 id: id,
1258 created: created,
1259 updated: updated,
1260 status: status,
1261 note: note,
1262 tags: tags,
1263 visibility: visibility,
1264 image: image,
1265 tasks: tasks
1266 )
1267 }
1268}