krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.4: 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 private(set) var projects: [Project] = []
176 var assignedTickets: [HomeAssignedTicket] = []
177 var recentBuilds: [HomeBuildItem] = []
178 var unreadInboxThreads: [InboxThreadSummary] = []
179 private(set) var systemStatusSnapshot: SystemStatusSnapshot?
180 private(set) var isLoadingSystemStatus = false
181 private(set) var isShowingStaleSystemStatus = false
182 private(set) var systemStatusErrorMessage: String?
183 private(set) var hasUnreadInboxThreads = false
184 private(set) var unreadInboxThreadCount: Int?
185 private(set) var isLoadingProjects = false
186 private(set) var isLoadingAssignedTickets = false
187 private(set) var isLoadingRecentBuilds = false
188 private(set) var projectsError: String?
189 private(set) var assignedTicketsError: String?
190 private(set) var recentBuildsError: String?
191 private(set) var lastRefreshed: Date?
192
193 private let currentUser: User
194 private let client: SRHTClient
195 private let systemStatusRepository: SystemStatusRepository
196 private let projectService: ProjectService
197 private let ticketFetchConcurrencyLimit = 6
198 private let inboxUnreadConcurrencyLimit = 4
199
200 private var currentUserKey: String {
201 currentUser.canonicalName
202 }
203
204 private static let jobsQuery = """
205 query jobs {
206 jobs {
207 results {
208 id
209 created
210 updated
211 status
212 note
213 tags
214 visibility
215 image
216 tasks { name status }
217 manifest
218 }
219 }
220 }
221 """
222
223 private static let trackersQuery = """
224 query trackers($cursor: Cursor) {
225 trackers(cursor: $cursor) {
226 results {
227 id
228 rid
229 name
230 description
231 visibility
232 updated
233 owner { canonicalName }
234 }
235 cursor
236 }
237 }
238 """
239
240 private static let trackerTicketsQuery = """
241 query tickets($owner: String!, $tracker: String!) {
242 user(username: $owner) {
243 tracker(name: $tracker) {
244 tickets {
245 results {
246 id
247 subject
248 status
249 resolution
250 created
251 submitter { canonicalName }
252 labels { id name backgroundColor foregroundColor }
253 assignees { canonicalName }
254 }
255 }
256 }
257 }
258 }
259 """
260
261 private static let inboxSubscriptionsQuery = """
262 query inboxSubscriptions($cursor: Cursor) {
263 subscriptions(cursor: $cursor) {
264 results {
265 ... on MailingListSubscription {
266 list {
267 id
268 rid
269 name
270 owner { canonicalName }
271 }
272 }
273 }
274 cursor
275 }
276 }
277 """
278
279 private static let inboxListThreadsQuery = """
280 query inboxListThreads($rid: ID!, $cursor: Cursor) {
281 list(rid: $rid) {
282 threads(cursor: $cursor) {
283 results {
284 created
285 updated
286 subject
287 replies
288 sender { canonicalName }
289 root {
290 id
291 subject
292 date
293 received
294 messageID
295 body
296 patch { subject }
297 }
298 }
299 cursor
300 }
301 }
302 }
303 """
304
305 private static let updateTicketStatusMutation = """
306 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
307 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
308 eventType: __typename
309 }
310 }
311 """
312
313 private static let unassignUserMutation = """
314 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
315 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
316 }
317 """
318
319 private static let cancelBuildMutation = """
320 mutation cancel($id: Int!) {
321 cancel(jobId: $id) { id }
322 }
323 """
324
325 private let defaults: UserDefaults
326 private let accountID: String
327
328 init(
329 currentUser: User,
330 client: SRHTClient,
331 systemStatusRepository: SystemStatusRepository,
332 defaults: UserDefaults,
333 accountID: String
334 ) {
335 self.currentUser = currentUser
336 self.client = client
337 self.systemStatusRepository = systemStatusRepository
338 self.projectService = ProjectService(client: client)
339 self.defaults = defaults
340 self.accountID = accountID
341 }
342
343 func loadDashboard() async {
344 isLoadingProjects = true
345 isLoadingAssignedTickets = true
346 isLoadingRecentBuilds = true
347 isLoadingSystemStatus = true
348 projectsError = nil
349 assignedTicketsError = nil
350 recentBuildsError = nil
351 isShowingStaleSystemStatus = false
352 systemStatusErrorMessage = nil
353
354 async let projectsTask = loadProjects()
355 async let jobsTask = loadRecentJobs()
356 async let assignedTicketsTask = loadAssignedTickets()
357 async let inboxUnreadTask = loadInboxUnreadSnapshot()
358 async let systemStatusTask = loadSystemStatusSnapshot()
359
360 let projectsResult = await projectsTask
361 switch projectsResult {
362 case .success(let projects):
363 self.projects = projects
364 self.projectsError = nil
365 case .failure(let error):
366 self.projectsError = error.userFacingMessage
367 }
368 isLoadingProjects = false
369
370 let recentJobsResult = await jobsTask
371
372 switch recentJobsResult {
373 case .success(let recentJobs):
374 let buildItems = Self.buildItems(from: recentJobs)
375 self.recentBuilds = buildItems
376 self.recentBuildsError = nil
377 case .failure(let error):
378 self.recentBuilds = []
379 self.recentBuildsError = error.userFacingMessage
380 }
381 isLoadingRecentBuilds = false
382
383 let assignedTicketsResult = await assignedTicketsTask
384
385 switch assignedTicketsResult {
386 case .success(let assignedTickets):
387 self.assignedTickets = assignedTickets
388 self.assignedTicketsError = nil
389 case .failure(let error):
390 self.assignedTickets = []
391 self.assignedTicketsError = error.userFacingMessage
392 }
393 isLoadingAssignedTickets = false
394
395 let inboxUnreadSnapshot = await inboxUnreadTask
396 unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
397 unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
398 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
399 let systemStatusResult = await systemStatusTask
400 switch systemStatusResult {
401 case .success(let result):
402 systemStatusSnapshot = result.value
403 isShowingStaleSystemStatus = result.isStale
404 systemStatusErrorMessage = result.isStale ? result.refreshErrorMessage : nil
405 case .failure(let error):
406 systemStatusErrorMessage = error.userFacingMessage
407 }
408 isLoadingSystemStatus = false
409 lastRefreshed = Date()
410 persistNeedsAttentionSnapshot()
411 persistSystemStatusWidgetSnapshot()
412 }
413
414 /// Returns true if sufficient time has elapsed since the last dashboard refresh.
415 func needsRefresh(after interval: TimeInterval = 60) -> Bool {
416 guard let lastRefreshed else { return true }
417 return Date().timeIntervalSince(lastRefreshed) > interval
418 }
419
420 var hasDashboardContent: Bool {
421 !pinnedProjects.isEmpty || !assignedTickets.isEmpty || !recentBuilds.isEmpty || !unreadInboxThreads.isEmpty || systemStatusSnapshot != nil
422 }
423
424 var pinnedProjects: [Project] {
425 let pinnedIDs = HomePinStore.pinnedProjectIDs(for: currentUserKey, defaults: defaults)
426 guard !pinnedIDs.isEmpty else { return [] }
427
428 let projectsByID = Dictionary(uniqueKeysWithValues: projects.map { ($0.id, $0) })
429 return pinnedIDs.compactMap { projectsByID[$0] }
430 }
431
432 var hasPinnedProjects: Bool {
433 !HomePinStore.loadPins(for: currentUserKey, defaults: defaults).isEmpty
434 }
435
436 var failedBuildCount: Int {
437 recentBuilds.filter {
438 switch $0.job.status {
439 case .failed, .timeout:
440 return true
441 default:
442 return false
443 }
444 }.count
445 }
446
447 var activeBuildCount: Int {
448 recentBuilds.filter {
449 switch $0.job.status {
450 case .pending, .queued, .running:
451 return true
452 default:
453 return false
454 }
455 }.count
456 }
457
458 var activeIncidentCount: Int {
459 systemStatusSnapshot?.activeIncidents.count ?? 0
460 }
461
462 var disruptedServiceCount: Int {
463 systemStatusSnapshot?.disruptedServices.count ?? 0
464 }
465
466 var needsAttentionCount: Int {
467 var count = 0
468 if let unreadInboxThreadCount {
469 count += unreadInboxThreadCount
470 }
471 count += assignedTickets.count
472 count += failedBuildCount
473 count += activeBuildCount
474 if let snapshot = systemStatusSnapshot, snapshot.hasDisruption {
475 count += max(snapshot.disruptedServices.count, snapshot.activeIncidents.count)
476 }
477 return count
478 }
479
480 var attentionSummaryText: String {
481 if needsAttentionCount == 0 {
482 return "All clear"
483 }
484 var parts: [String] = []
485 if let unreadInboxThreadCount, unreadInboxThreadCount > 0 {
486 parts.append(Self.countLabel(unreadInboxThreadCount, singular: "unread thread"))
487 }
488 if !assignedTickets.isEmpty {
489 parts.append(Self.countLabel(assignedTickets.count, singular: "assigned ticket"))
490 }
491 if failedBuildCount > 0 {
492 parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
493 }
494 if activeBuildCount > 0 {
495 parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
496 }
497 if disruptedServiceCount > 0 {
498 parts.append(Self.countLabel(disruptedServiceCount, singular: "service issue"))
499 }
500 return parts.joined(separator: " • ")
501 }
502
503 var inboxSummaryText: String {
504 guard let unreadInboxThreadCount else { return "Inbox status unavailable" }
505 if unreadInboxThreadCount == 0 {
506 return "Inbox zero"
507 }
508 return "\(Self.countLabel(unreadInboxThreadCount, singular: "unread thread")) across your lists"
509 }
510
511 var ticketsSummaryText: String {
512 if assignedTickets.isEmpty {
513 return "No open tickets assigned to you"
514 }
515 return Self.countLabel(assignedTickets.count, singular: "open assigned ticket")
516 }
517
518 var buildsSummaryText: String {
519 if recentBuilds.isEmpty {
520 return "No recent builds"
521 }
522 var parts: [String] = []
523 if failedBuildCount > 0 {
524 parts.append(Self.countLabel(failedBuildCount, singular: "failed build"))
525 }
526 if activeBuildCount > 0 {
527 parts.append(Self.countLabel(activeBuildCount, singular: "active build"))
528 }
529 if parts.isEmpty {
530 return "Recent builds are clear"
531 }
532 return parts.joined(separator: " • ")
533 }
534
535 var systemSummaryText: String {
536 guard let systemStatusSnapshot else {
537 return systemStatusErrorMessage ?? "System status unavailable"
538 }
539 if systemStatusSnapshot.hasDisruption {
540 return systemStatusSnapshot.bannerSummary
541 }
542 return systemStatusSnapshot.overallStatusText
543 }
544
545 func resolveTicket(_ ticket: HomeAssignedTicket) async {
546 let input: [String: any Sendable] = [
547 "status": TicketStatus.resolved.rawValue,
548 "resolution": TicketResolution.fixed.rawValue
549 ]
550 await performTicketStatusUpdate(ticket: ticket, input: input)
551 }
552
553 func reopenTicket(_ ticket: HomeAssignedTicket) async {
554 let input: [String: any Sendable] = [
555 "status": TicketStatus.reported.rawValue
556 ]
557 await performTicketStatusUpdate(ticket: ticket, input: input)
558 }
559
560 func unassignFromMe(_ ticket: HomeAssignedTicket) async {
561 do {
562 _ = try await client.execute(
563 service: .todo,
564 query: Self.unassignUserMutation,
565 variables: [
566 "trackerId": ticket.trackerId,
567 "ticketId": ticket.ticket.id,
568 "userId": currentUser.id
569 ],
570 responseType: UnassignResponse.self
571 )
572 assignedTickets.removeAll { $0.id == ticket.id }
573 persistNeedsAttentionSnapshot()
574 } catch {
575 homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
576 }
577 }
578
579 func cancelBuild(_ build: HomeBuildItem) async {
580 guard build.job.status.isCancellable else { return }
581
582 do {
583 _ = try await client.execute(
584 service: .builds,
585 query: Self.cancelBuildMutation,
586 variables: ["id": build.job.id],
587 responseType: CancelBuildResponse.self
588 )
589 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
590 let updatedJob = JobSummary(
591 id: build.job.id,
592 created: build.job.created,
593 updated: build.job.updated,
594 status: .cancelled,
595 note: build.job.note,
596 tags: build.job.tags,
597 visibility: build.job.visibility,
598 image: build.job.image,
599 tasks: build.job.tasks
600 )
601 recentBuilds[index] = HomeBuildItem(
602 job: updatedJob,
603 repositoryName: build.repositoryName,
604 repositoryOwner: build.repositoryOwner
605 )
606 }
607 persistNeedsAttentionSnapshot()
608 } catch {
609 homeLogger.error("Cancel build failed: \(error, privacy: .public)")
610 }
611 }
612
613 func markInboxThreadRead(_ thread: InboxThreadSummary) {
614 InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id, defaults: defaults)
615 unreadInboxThreads.removeAll { $0.id == thread.id }
616 unreadInboxThreadCount = max((unreadInboxThreadCount ?? 1) - 1, 0)
617 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
618 persistNeedsAttentionSnapshot()
619 }
620
621 func markInboxThreadUnread(_ thread: InboxThreadSummary) {
622 InboxReadStateStore.markUnread(for: thread.id, defaults: defaults)
623 if unreadInboxThreads.contains(where: { $0.id == thread.id }) == false {
624 unreadInboxThreads.append(
625 InboxThreadSummary(
626 rootEmailID: thread.rootEmailID,
627 rootMessageID: thread.rootMessageID,
628 threadRootEmailIDs: thread.threadRootEmailIDs,
629 threadRootMessageIDs: thread.threadRootMessageIDs,
630 listID: thread.listID,
631 listRID: thread.listRID,
632 listName: thread.listName,
633 listOwner: thread.listOwner,
634 subject: thread.subject,
635 latestSender: thread.latestSender,
636 lastActivityAt: thread.lastActivityAt,
637 messageCount: thread.messageCount,
638 repo: thread.repo,
639 containsPatch: thread.containsPatch,
640 isUnread: true
641 )
642 )
643 unreadInboxThreads.sort(by: Self.sortInboxThreadsForTriage)
644 }
645 unreadInboxThreadCount = (unreadInboxThreadCount ?? 0) + 1
646 hasUnreadInboxThreads = true
647 persistNeedsAttentionSnapshot()
648 }
649
650 private func loadProjects() async -> Result<[Project], Error> {
651 do {
652 return .success(try await projectService.fetchProjects())
653 } catch {
654 return .failure(error)
655 }
656 }
657
658 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
659 do {
660 let response = try await client.execute(
661 service: .builds,
662 query: Self.jobsQuery,
663 responseType: HomeJobsResponse.self
664 )
665 return .success(response.jobs.results)
666 } catch {
667 return .failure(error)
668 }
669 }
670
671 private func loadInboxUnreadSnapshot() async -> HomeInboxUnreadSnapshot? {
672 do {
673 return try await fetchUnreadInboxSnapshot()
674 } catch {
675 return nil
676 }
677 }
678
679 private func loadSystemStatusSnapshot() async -> Result<CachedSystemStatusValue<SystemStatusSnapshot>, Error> {
680 do {
681 return .success(try await systemStatusRepository.snapshotResult())
682 } catch {
683 return .failure(error)
684 }
685 }
686
687 private func fetchUnreadInboxSnapshot() async throws -> HomeInboxUnreadSnapshot {
688 let mailingLists = try await fetchInboxMailingLists()
689 guard !mailingLists.isEmpty else { return HomeInboxUnreadSnapshot(unreadCount: 0, threads: []) }
690
691 var startIndex = mailingLists.startIndex
692 var unreadCount = 0
693 var successfulFetchCount = 0
694 var unreadThreads: [InboxThreadSummary] = []
695 while startIndex < mailingLists.endIndex {
696 let endIndex = mailingLists.index(
697 startIndex,
698 offsetBy: inboxUnreadConcurrencyLimit,
699 limitedBy: mailingLists.endIndex
700 ) ?? mailingLists.endIndex
701 let batch = Array(mailingLists[startIndex..<endIndex])
702
703 let batchResult = await withTaskGroup(of: Result<HomeInboxUnreadSnapshot, Error>.self) { group in
704 for mailingList in batch {
705 group.addTask {
706 do {
707 return .success(try await self.fetchUnreadThreadSnapshot(for: mailingList))
708 } catch {
709 return .failure(error)
710 }
711 }
712 }
713
714 var snapshots: [HomeInboxUnreadSnapshot] = []
715 var errors: [Error] = []
716 for await result in group {
717 switch result {
718 case .success(let snapshot):
719 snapshots.append(snapshot)
720 case .failure(let error):
721 errors.append(error)
722 }
723 }
724 return (snapshots, errors)
725 }
726
727 unreadCount += batchResult.0.reduce(0) { $0 + $1.unreadCount }
728 unreadThreads.append(contentsOf: batchResult.0.flatMap(\.threads))
729 successfulFetchCount += batchResult.0.count
730
731 startIndex = endIndex
732 }
733
734 guard successfulFetchCount > 0 else {
735 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
736 }
737
738 return HomeInboxUnreadSnapshot(
739 unreadCount: unreadCount,
740 threads: unreadThreads.sorted(by: Self.sortInboxThreadsForTriage)
741 )
742 }
743
744 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
745 var subscriptions: [HomeInboxSubscription] = []
746 var cursor: String?
747
748 while true {
749 var variables: [String: any Sendable] = [:]
750 if let cursor {
751 variables["cursor"] = cursor
752 }
753
754 let response = try await client.execute(
755 service: .lists,
756 query: Self.inboxSubscriptionsQuery,
757 variables: variables.isEmpty ? nil : variables,
758 responseType: HomeInboxSubscriptionsResponse.self
759 )
760
761 subscriptions.append(contentsOf: response.subscriptions.results)
762 guard let nextCursor = response.subscriptions.cursor else {
763 break
764 }
765 cursor = nextCursor
766 }
767
768 var seen = Set<String>()
769 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
770 }
771
772 private func fetchUnreadThreadSnapshot(for mailingList: InboxMailingListReference) async throws -> HomeInboxUnreadSnapshot {
773 var unreadCount = 0
774 var cursor: String?
775 var unreadThreads: [InboxThreadSummary] = []
776
777 while true {
778 var variables: [String: any Sendable] = ["rid": mailingList.rid]
779 if let cursor {
780 variables["cursor"] = cursor
781 }
782
783 let response = try await client.execute(
784 service: .lists,
785 query: Self.inboxListThreadsQuery,
786 variables: variables,
787 responseType: HomeInboxListThreadsResponse.self
788 )
789
790 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
791 let summary = InboxThreadSummary(
792 rootEmailID: thread.root.id,
793 rootMessageID: thread.root.messageID,
794 threadRootEmailIDs: [thread.root.id],
795 threadRootMessageIDs: [thread.root.messageID],
796 listID: mailingList.id,
797 listRID: mailingList.rid,
798 listName: mailingList.name,
799 listOwner: mailingList.owner,
800 subject: thread.subject,
801 latestSender: thread.sender,
802 lastActivityAt: thread.updated,
803 messageCount: thread.replies + 1,
804 repo: InboxViewModel.deriveRepositoryName(from: mailingList.name),
805 containsPatch: thread.root.patch != nil || thread.subject.localizedCaseInsensitiveContains("[patch"),
806 isUnread: InboxReadStateStore.isUnread(
807 threadID: "\(mailingList.rid)#\(InboxThreadSummary.normalizationKey(for: thread.subject))",
808 lastActivityAt: thread.updated,
809 defaults: defaults
810 )
811 )
812 return summary.isUnread ? summary : nil
813 }
814 unreadCount += unreadThreadSummaries.count
815 unreadThreads.append(contentsOf: unreadThreadSummaries)
816
817 guard let nextCursor = response.list.threads.cursor else {
818 break
819 }
820 cursor = nextCursor
821 }
822
823 return HomeInboxUnreadSnapshot(
824 unreadCount: unreadCount,
825 threads: unreadThreads
826 )
827 }
828
829 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
830 do {
831 let trackers = try await fetchAllTrackers()
832 let tickets = try await fetchAssignedTickets(for: trackers)
833 .sorted(by: Self.sortAssignedTicketsForTriage)
834 return .success(tickets)
835 } catch {
836 return .failure(error)
837 }
838 }
839
840 private func fetchAllTrackers() async throws -> [TrackerSummary] {
841 var allTrackers: [TrackerSummary] = []
842 var cursor: String?
843
844 while true {
845 var variables: [String: any Sendable] = [:]
846 if let cursor {
847 variables["cursor"] = cursor
848 }
849
850 let response = try await client.execute(
851 service: .todo,
852 query: Self.trackersQuery,
853 variables: variables.isEmpty ? nil : variables,
854 responseType: HomeTrackersResponse.self
855 )
856
857 allTrackers.append(contentsOf: response.trackers.results)
858 guard let nextCursor = response.trackers.cursor else {
859 break
860 }
861 cursor = nextCursor
862 }
863
864 return allTrackers
865 }
866
867 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
868 guard !trackers.isEmpty else { return [] }
869
870 var assignedTickets: [HomeAssignedTicket] = []
871 var startIndex = trackers.startIndex
872
873 while startIndex < trackers.endIndex {
874 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
875 let batch = Array(trackers[startIndex..<endIndex])
876
877 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
878 for tracker in batch {
879 group.addTask {
880 try await self.fetchAssignedTickets(for: tracker)
881 }
882 }
883
884 var ticketsForBatch: [HomeAssignedTicket] = []
885 for try await tickets in group {
886 ticketsForBatch.append(contentsOf: tickets)
887 }
888 return ticketsForBatch
889 }
890
891 assignedTickets.append(contentsOf: batchTickets)
892 startIndex = endIndex
893 }
894
895 return assignedTickets
896 }
897
898 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
899 let response = try await client.execute(
900 service: .todo,
901 query: Self.trackerTicketsQuery,
902 variables: [
903 "owner": tracker.owner.canonicalName.hasPrefix("~")
904 ? String(tracker.owner.canonicalName.dropFirst())
905 : tracker.owner.canonicalName,
906 "tracker": tracker.name
907 ],
908 responseType: HomeTrackerTicketsResponse.self
909 )
910
911 return response.user.tracker.tickets.results.compactMap { payload in
912 guard payload.status.isOpen else {
913 return nil
914 }
915 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
916 return nil
917 }
918
919 return HomeAssignedTicket(
920 trackerId: tracker.id,
921 trackerRid: tracker.rid,
922 trackerName: tracker.name,
923 ownerCanonicalName: tracker.owner.canonicalName,
924 ticket: payload.ticketSummary
925 )
926 }
927 }
928
929 private func performTicketStatusUpdate(
930 ticket: HomeAssignedTicket,
931 input: [String: any Sendable]
932 ) async {
933 do {
934 _ = try await client.execute(
935 service: .todo,
936 query: Self.updateTicketStatusMutation,
937 variables: [
938 "trackerId": ticket.trackerId,
939 "ticketId": ticket.ticket.id,
940 "input": input
941 ],
942 responseType: StatusEventResponse.self
943 )
944 assignedTickets.removeAll { $0.id == ticket.id }
945 persistNeedsAttentionSnapshot()
946 } catch {
947 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
948 }
949 }
950
951 private func persistNeedsAttentionSnapshot() {
952 NeedsAttentionSnapshotStore.save(
953 NeedsAttentionSnapshot(
954 unreadInboxThreads: unreadInboxThreadCount,
955 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
956 failedBuilds: recentBuildsError == nil
957 ? recentBuilds.filter {
958 switch $0.job.status {
959 case .failed, .timeout:
960 true
961 default:
962 false
963 }
964 }.count
965 : nil,
966 updatedAt: .now
967 ),
968 accountID: accountID
969 )
970 }
971
972 private func persistSystemStatusWidgetSnapshot() {
973 guard let snapshot = systemStatusSnapshot else {
974 return
975 }
976 let widgetSnapshot = SystemStatusWidgetSnapshot(
977 services: snapshot.services.map { service in
978 SystemStatusWidgetSnapshot.ServiceEntry(
979 id: service.id,
980 name: service.name,
981 status: service.status.displayName,
982 requiresAttention: service.status.requiresAttention
983 )
984 },
985 hasDisruption: snapshot.hasDisruption,
986 overallStatusText: snapshot.overallStatusText,
987 bannerSummary: snapshot.bannerSummary,
988 updatedAt: .now
989 )
990 SystemStatusWidgetSnapshotStore.save(widgetSnapshot, accountID: accountID)
991 }
992
993 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
994 jobs.map { job in
995 let repository = primaryRepositoryReference(in: job.manifest)
996 return HomeBuildItem(
997 job: job.jobSummary,
998 repositoryName: repository?.name,
999 repositoryOwner: repository?.ownerCanonicalName
1000 )
1001 }
1002 .sorted(by: sortBuildItemsForTriage)
1003 }
1004
1005 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
1006 buildItems(from: jobs).filter { build in
1007 switch build.job.status {
1008 case .failed, .timeout:
1009 true
1010 default:
1011 false
1012 }
1013 }
1014 }
1015
1016 nonisolated static func sortBuildItemsForTriage(_ lhs: HomeBuildItem, _ rhs: HomeBuildItem) -> Bool {
1017 let lhsPriority = buildPriority(for: lhs.job.status)
1018 let rhsPriority = buildPriority(for: rhs.job.status)
1019 if lhsPriority != rhsPriority {
1020 return lhsPriority < rhsPriority
1021 }
1022 if lhs.job.updated != rhs.job.updated {
1023 return lhs.job.updated > rhs.job.updated
1024 }
1025 return lhs.job.id > rhs.job.id
1026 }
1027
1028 nonisolated static func sortAssignedTicketsForTriage(_ lhs: HomeAssignedTicket, _ rhs: HomeAssignedTicket) -> Bool {
1029 if lhs.ticket.created != rhs.ticket.created {
1030 return lhs.ticket.created < rhs.ticket.created
1031 }
1032 return lhs.ticket.id < rhs.ticket.id
1033 }
1034
1035 nonisolated static func sortInboxThreadsForTriage(_ lhs: InboxThreadSummary, _ rhs: InboxThreadSummary) -> Bool {
1036 if lhs.containsPatch != rhs.containsPatch {
1037 return lhs.containsPatch && !rhs.containsPatch
1038 }
1039 if lhs.lastActivityAt != rhs.lastActivityAt {
1040 return lhs.lastActivityAt > rhs.lastActivityAt
1041 }
1042 return InboxThreadSummary.normalizationKey(for: lhs.subject)
1043 .localizedCaseInsensitiveCompare(InboxThreadSummary.normalizationKey(for: rhs.subject)) == .orderedAscending
1044 }
1045
1046 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
1047 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
1048 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
1049 if assigneeCanonical == currentCanonical {
1050 return true
1051 }
1052
1053 let assigneeUsername = normalizedUsername(entity.canonicalName)
1054 let currentUsername = normalizedUsername(currentUser.username)
1055 return assigneeUsername == currentUsername
1056 }
1057
1058 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
1059 guard let manifest else { return nil }
1060 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
1061 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
1062 return nil
1063 }
1064 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
1065 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
1066 let ownerRange = Range(match.range(at: 1), in: manifest),
1067 let nameRange = Range(match.range(at: 2), in: manifest) else {
1068 return nil
1069 }
1070
1071 let owner = String(manifest[ownerRange])
1072 var name = String(manifest[nameRange])
1073 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
1074 name.removeSubrange(suffixRange)
1075 }
1076 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
1077 guard !name.isEmpty else { return nil }
1078 return (owner, name)
1079 }
1080
1081 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
1082 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1083 guard !trimmed.isEmpty else { return trimmed }
1084 if trimmed.hasPrefix("~") {
1085 return trimmed
1086 }
1087 return "~\(trimmed)"
1088 }
1089
1090 private nonisolated static func normalizedUsername(_ value: String) -> String {
1091 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
1092 if trimmed.hasPrefix("~") {
1093 return String(trimmed.dropFirst())
1094 }
1095 return trimmed
1096 }
1097
1098 private nonisolated static func buildPriority(for status: JobStatus) -> Int {
1099 switch status {
1100 case .failed, .timeout:
1101 return 0
1102 case .running:
1103 return 1
1104 case .queued, .pending:
1105 return 2
1106 case .cancelled:
1107 return 3
1108 case .success:
1109 return 4
1110 }
1111 }
1112
1113 private nonisolated static func countLabel(_ count: Int, singular: String) -> String {
1114 count == 1 ? "1 \(singular)" : "\(count) \(singular)s"
1115 }
1116
1117 private struct StatusEventResponse: Decodable, Sendable {
1118 struct EventRef: Decodable, Sendable {
1119 let eventType: String
1120 }
1121
1122 let updateTicketStatus: EventRef
1123 }
1124
1125 private struct UnassignResponse: Decodable, Sendable {
1126 struct EventRef: Decodable, Sendable {
1127 let id: Int
1128 }
1129
1130 let unassignUser: EventRef
1131 }
1132
1133 private struct CancelBuildResponse: Decodable, Sendable {
1134 struct CancelResult: Decodable, Sendable {
1135 let id: Int
1136 }
1137
1138 let cancel: CancelResult
1139 }
1140}
1141struct HomeJobPayload: Decodable, Sendable {
1142 let id: Int
1143 let created: Date
1144 let updated: Date
1145 let status: JobStatus
1146 let note: String?
1147 let tags: [String]
1148 let visibility: Visibility?
1149 let image: String?
1150 let tasks: [JobTaskSummary]
1151 let manifest: String?
1152
1153 nonisolated var jobSummary: JobSummary {
1154 JobSummary(
1155 id: id,
1156 created: created,
1157 updated: updated,
1158 status: status,
1159 note: note,
1160 tags: tags,
1161 visibility: visibility,
1162 image: image,
1163 tasks: tasks
1164 )
1165 }
1166}