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