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