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