krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.7.1: Hutch/Views/Home/HomeViewModel.swift · raw
1import Foundation
2import os
3
4private let homeLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Home")
5
6private struct HomeJobsResponse: Decodable, Sendable {
7 let jobs: HomeJobsPage
8}
9
10private struct HomeJobsPage: Decodable, Sendable {
11 let results: [HomeJobPayload]
12}
13
14private struct HomeTrackersResponse: Decodable, Sendable {
15 let trackers: HomeTrackersPage
16}
17
18private struct HomeTrackersPage: Decodable, Sendable {
19 let results: [TrackerSummary]
20 let cursor: String?
21}
22
23private struct HomeTrackerTicketsResponse: Decodable, Sendable {
24 let user: HomeTrackerTicketsUser
25}
26
27private struct HomeTrackerTicketsUser: Decodable, Sendable {
28 let tracker: HomeTrackerTicketsTracker
29}
30
31private struct HomeTrackerTicketsTracker: Decodable, Sendable {
32 let tickets: HomeTrackerTicketsPage
33}
34
35private struct HomeTrackerTicketsPage: Decodable, Sendable {
36 let results: [HomeTicketPayload]
37}
38
39private struct HomeInboxSubscriptionsResponse: Decodable, Sendable {
40 let subscriptions: HomeInboxSubscriptionPage
41}
42
43private struct HomeInboxSubscriptionPage: Decodable, Sendable {
44 let results: [HomeInboxSubscription]
45 let cursor: String?
46}
47
48private struct HomeInboxSubscription: Decodable, Sendable {
49 let list: InboxMailingListReference?
50}
51
52private struct HomeInboxListThreadsResponse: Decodable, Sendable {
53 let list: HomeInboxMailingListThreads
54}
55
56private struct HomeInboxMailingListThreads: Decodable, Sendable {
57 let threads: HomeInboxThreadPage
58}
59
60private struct HomeInboxThreadPage: Decodable, Sendable {
61 let results: [HomeInboxThreadPayload]
62 let cursor: String?
63}
64
65private struct HomeInboxThreadPayload: Decodable, Sendable {
66 let updated: Date
67 let subject: String
68}
69
70private struct HomeTicketPayload: Decodable, Sendable {
71 let id: Int
72 let title: String
73 let status: TicketStatus
74 let resolution: TicketResolution?
75 let created: Date
76 let submitter: Entity
77 let labels: [TicketLabel]
78 let assignees: [Entity]
79
80 enum CodingKeys: String, CodingKey {
81 case id
82 case title = "subject"
83 case status
84 case resolution
85 case created
86 case submitter
87 case labels
88 case assignees
89 }
90
91 var ticketSummary: TicketSummary {
92 TicketSummary(
93 id: id,
94 title: title,
95 status: status,
96 resolution: resolution,
97 created: created,
98 submitter: submitter,
99 labels: labels,
100 assignees: assignees
101 )
102 }
103}
104
105struct HomeAssignedTicket: Identifiable, Hashable, Sendable {
106 let trackerId: Int
107 let trackerRid: String
108 let trackerName: String
109 let ownerCanonicalName: String
110 let ticket: TicketSummary
111
112 var id: String {
113 "\(trackerRid)#\(ticket.id)"
114 }
115
116 var ownerUsername: String {
117 if ownerCanonicalName.hasPrefix("~") {
118 return String(ownerCanonicalName.dropFirst())
119 }
120 return ownerCanonicalName
121 }
122}
123
124struct HomeBuildItem: Identifiable, Hashable, Sendable {
125 let job: JobSummary
126 let repositoryName: String?
127 let repositoryOwner: String?
128
129 var id: Int { job.id }
130
131 var repositoryDisplayName: String? {
132 guard let repositoryName else { return nil }
133 if let repositoryOwner {
134 return "\(repositoryOwner)/\(repositoryName)"
135 }
136 return repositoryName
137 }
138}
139
140@Observable
141@MainActor
142final class HomeViewModel {
143 private(set) var projects: [Project] = []
144 var assignedTickets: [HomeAssignedTicket] = []
145 var recentBuilds: [HomeBuildItem] = []
146 private(set) var hasUnreadInboxThreads = false
147 private(set) var unreadInboxThreadCount: Int?
148 private(set) var isLoadingProjects = false
149 private(set) var isLoadingAssignedTickets = false
150 private(set) var isLoadingRecentBuilds = false
151 private(set) var assignedTicketsError: String?
152 private(set) var recentBuildsError: String?
153
154 private let currentUser: User
155 private let client: SRHTClient
156 private let projectService: ProjectService
157 private let ticketFetchConcurrencyLimit = 6
158 private let inboxUnreadConcurrencyLimit = 4
159
160 private static let jobsQuery = """
161 query jobs {
162 jobs {
163 results {
164 id
165 created
166 updated
167 status
168 note
169 tags
170 visibility
171 image
172 tasks { name status }
173 manifest
174 }
175 }
176 }
177 """
178
179 private static let trackersQuery = """
180 query trackers($cursor: Cursor) {
181 trackers(cursor: $cursor) {
182 results {
183 id
184 rid
185 name
186 description
187 visibility
188 updated
189 owner { canonicalName }
190 }
191 cursor
192 }
193 }
194 """
195
196 private static let trackerTicketsQuery = """
197 query tickets($owner: String!, $tracker: String!) {
198 user(username: $owner) {
199 tracker(name: $tracker) {
200 tickets {
201 results {
202 id
203 subject
204 status
205 resolution
206 created
207 submitter { canonicalName }
208 labels { id name backgroundColor foregroundColor }
209 assignees { canonicalName }
210 }
211 }
212 }
213 }
214 }
215 """
216
217 private static let inboxSubscriptionsQuery = """
218 query inboxSubscriptions($cursor: Cursor) {
219 subscriptions(cursor: $cursor) {
220 results {
221 ... on MailingListSubscription {
222 list {
223 id
224 rid
225 name
226 owner { canonicalName }
227 }
228 }
229 }
230 cursor
231 }
232 }
233 """
234
235 private static let inboxListThreadsQuery = """
236 query inboxListThreads($rid: ID!, $cursor: Cursor) {
237 list(rid: $rid) {
238 threads(cursor: $cursor) {
239 results {
240 updated
241 subject
242 }
243 cursor
244 }
245 }
246 }
247 """
248
249 private static let updateTicketStatusMutation = """
250 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
251 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
252 eventType: __typename
253 }
254 }
255 """
256
257 private static let unassignUserMutation = """
258 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
259 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
260 }
261 """
262
263 private static let cancelBuildMutation = """
264 mutation cancel($id: Int!) {
265 cancel(jobId: $id) { id }
266 }
267 """
268
269 init(currentUser: User, client: SRHTClient) {
270 self.currentUser = currentUser
271 self.client = client
272 self.projectService = ProjectService(client: client)
273 }
274
275 func loadDashboard() async {
276 isLoadingProjects = true
277 isLoadingAssignedTickets = true
278 isLoadingRecentBuilds = true
279 assignedTicketsError = nil
280 recentBuildsError = nil
281
282 async let projectsTask = loadProjects()
283 async let jobsTask = loadRecentJobs()
284 async let assignedTicketsTask = loadAssignedTickets()
285 async let inboxUnreadTask = loadInboxUnreadCount()
286
287 let projectsResult = await projectsTask
288 switch projectsResult {
289 case .success(let projects):
290 self.projects = projects
291 case .failure:
292 self.projects = []
293 }
294 isLoadingProjects = false
295
296 let recentJobsResult = await jobsTask
297
298 switch recentJobsResult {
299 case .success(let recentJobs):
300 let buildItems = Self.buildItems(from: recentJobs)
301 self.recentBuilds = buildItems
302 self.recentBuildsError = nil
303 case .failure(let error):
304 self.recentBuilds = []
305 self.recentBuildsError = error.userFacingMessage
306 }
307 isLoadingRecentBuilds = false
308
309 let assignedTicketsResult = await assignedTicketsTask
310
311 switch assignedTicketsResult {
312 case .success(let assignedTickets):
313 self.assignedTickets = assignedTickets
314 self.assignedTicketsError = nil
315 case .failure(let error):
316 self.assignedTickets = []
317 self.assignedTicketsError = error.userFacingMessage
318 }
319 isLoadingAssignedTickets = false
320
321 unreadInboxThreadCount = await inboxUnreadTask
322 hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
323 persistNeedsAttentionSnapshot()
324 }
325
326 func resolveTicket(_ ticket: HomeAssignedTicket) async {
327 let input: [String: any Sendable] = [
328 "status": TicketStatus.resolved.rawValue,
329 "resolution": TicketResolution.fixed.rawValue
330 ]
331 await performTicketStatusUpdate(ticket: ticket, input: input)
332 }
333
334 func reopenTicket(_ ticket: HomeAssignedTicket) async {
335 let input: [String: any Sendable] = [
336 "status": TicketStatus.reported.rawValue
337 ]
338 await performTicketStatusUpdate(ticket: ticket, input: input)
339 }
340
341 func unassignFromMe(_ ticket: HomeAssignedTicket) async {
342 do {
343 _ = try await client.execute(
344 service: .todo,
345 query: Self.unassignUserMutation,
346 variables: [
347 "trackerId": ticket.trackerId,
348 "ticketId": ticket.ticket.id,
349 "userId": currentUser.id
350 ],
351 responseType: UnassignResponse.self
352 )
353 assignedTickets.removeAll { $0.id == ticket.id }
354 persistNeedsAttentionSnapshot()
355 } catch {
356 homeLogger.error("Unassign from me failed: \(error, privacy: .public)")
357 }
358 }
359
360 func cancelBuild(_ build: HomeBuildItem) async {
361 guard build.job.status.isCancellable else { return }
362
363 do {
364 _ = try await client.execute(
365 service: .builds,
366 query: Self.cancelBuildMutation,
367 variables: ["id": build.job.id],
368 responseType: CancelBuildResponse.self
369 )
370 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
371 let updatedJob = JobSummary(
372 id: build.job.id,
373 created: build.job.created,
374 updated: build.job.updated,
375 status: .cancelled,
376 note: build.job.note,
377 tags: build.job.tags,
378 visibility: build.job.visibility,
379 image: build.job.image,
380 tasks: build.job.tasks
381 )
382 recentBuilds[index] = HomeBuildItem(
383 job: updatedJob,
384 repositoryName: build.repositoryName,
385 repositoryOwner: build.repositoryOwner
386 )
387 }
388 persistNeedsAttentionSnapshot()
389 } catch {
390 homeLogger.error("Cancel build failed: \(error, privacy: .public)")
391 }
392 }
393
394 private func loadProjects() async -> Result<[Project], Error> {
395 do {
396 return .success(try await projectService.fetchProjects())
397 } catch {
398 return .failure(error)
399 }
400 }
401
402 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
403 do {
404 let response = try await client.execute(
405 service: .builds,
406 query: Self.jobsQuery,
407 responseType: HomeJobsResponse.self
408 )
409 return .success(response.jobs.results)
410 } catch {
411 return .failure(error)
412 }
413 }
414
415 private func loadInboxUnreadCount() async -> Int? {
416 do {
417 return try await fetchUnreadInboxThreadCount()
418 } catch {
419 return nil
420 }
421 }
422
423 private func fetchUnreadInboxThreadCount() async throws -> Int {
424 let mailingLists = try await fetchInboxMailingLists()
425 guard !mailingLists.isEmpty else { return 0 }
426
427 var startIndex = mailingLists.startIndex
428 var unreadCount = 0
429 var successfulFetchCount = 0
430 while startIndex < mailingLists.endIndex {
431 let endIndex = mailingLists.index(
432 startIndex,
433 offsetBy: inboxUnreadConcurrencyLimit,
434 limitedBy: mailingLists.endIndex
435 ) ?? mailingLists.endIndex
436 let batch = Array(mailingLists[startIndex..<endIndex])
437
438 let batchResult = await withTaskGroup(of: Result<Int, Error>.self) { group in
439 for mailingList in batch {
440 group.addTask {
441 do {
442 return .success(try await self.fetchUnreadThreadCount(for: mailingList))
443 } catch {
444 return .failure(error)
445 }
446 }
447 }
448
449 var counts: [Int] = []
450 var errors: [Error] = []
451 for await result in group {
452 switch result {
453 case .success(let count):
454 counts.append(count)
455 case .failure(let error):
456 errors.append(error)
457 }
458 }
459 return (counts, errors)
460 }
461
462 unreadCount += batchResult.0.reduce(0, +)
463 successfulFetchCount += batchResult.0.count
464
465 startIndex = endIndex
466 }
467
468 guard successfulFetchCount > 0 else {
469 throw SRHTError.graphQLErrors([GraphQLError(message: "Failed to load inbox threads", locations: nil)])
470 }
471
472 return unreadCount
473 }
474
475 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
476 var subscriptions: [HomeInboxSubscription] = []
477 var cursor: String?
478
479 while true {
480 var variables: [String: any Sendable] = [:]
481 if let cursor {
482 variables["cursor"] = cursor
483 }
484
485 let response = try await client.execute(
486 service: .lists,
487 query: Self.inboxSubscriptionsQuery,
488 variables: variables.isEmpty ? nil : variables,
489 responseType: HomeInboxSubscriptionsResponse.self
490 )
491
492 subscriptions.append(contentsOf: response.subscriptions.results)
493 guard let nextCursor = response.subscriptions.cursor else {
494 break
495 }
496 cursor = nextCursor
497 }
498
499 var seen = Set<String>()
500 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
501 }
502
503 private func fetchUnreadThreadCount(for mailingList: InboxMailingListReference) async throws -> Int {
504 var unreadCount = 0
505 var cursor: String?
506
507 while true {
508 var variables: [String: any Sendable] = ["rid": mailingList.rid]
509 if let cursor {
510 variables["cursor"] = cursor
511 }
512
513 let response = try await client.execute(
514 service: .lists,
515 query: Self.inboxListThreadsQuery,
516 variables: variables,
517 responseType: HomeInboxListThreadsResponse.self
518 )
519
520 unreadCount += response.list.threads.results.filter { thread in
521 let normalizedSubject = thread.subject
522 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
523 .trimmingCharacters(in: .whitespacesAndNewlines)
524 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
525 .lowercased()
526 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
527 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
528 }.count
529
530 guard let nextCursor = response.list.threads.cursor else {
531 break
532 }
533 cursor = nextCursor
534 }
535
536 return unreadCount
537 }
538
539 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
540 do {
541 let trackers = try await fetchAllTrackers()
542 let tickets = try await fetchAssignedTickets(for: trackers)
543 .sorted { $0.ticket.created > $1.ticket.created }
544 return .success(tickets)
545 } catch {
546 return .failure(error)
547 }
548 }
549
550 private func fetchAllTrackers() async throws -> [TrackerSummary] {
551 var allTrackers: [TrackerSummary] = []
552 var cursor: String?
553
554 while true {
555 var variables: [String: any Sendable] = [:]
556 if let cursor {
557 variables["cursor"] = cursor
558 }
559
560 let response = try await client.execute(
561 service: .todo,
562 query: Self.trackersQuery,
563 variables: variables.isEmpty ? nil : variables,
564 responseType: HomeTrackersResponse.self
565 )
566
567 allTrackers.append(contentsOf: response.trackers.results)
568 guard let nextCursor = response.trackers.cursor else {
569 break
570 }
571 cursor = nextCursor
572 }
573
574 return allTrackers
575 }
576
577 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
578 guard !trackers.isEmpty else { return [] }
579
580 var assignedTickets: [HomeAssignedTicket] = []
581 var startIndex = trackers.startIndex
582
583 while startIndex < trackers.endIndex {
584 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
585 let batch = Array(trackers[startIndex..<endIndex])
586
587 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
588 for tracker in batch {
589 group.addTask {
590 try await self.fetchAssignedTickets(for: tracker)
591 }
592 }
593
594 var ticketsForBatch: [HomeAssignedTicket] = []
595 for try await tickets in group {
596 ticketsForBatch.append(contentsOf: tickets)
597 }
598 return ticketsForBatch
599 }
600
601 assignedTickets.append(contentsOf: batchTickets)
602 startIndex = endIndex
603 }
604
605 return assignedTickets
606 }
607
608 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
609 let response = try await client.execute(
610 service: .todo,
611 query: Self.trackerTicketsQuery,
612 variables: [
613 "owner": tracker.owner.canonicalName.hasPrefix("~")
614 ? String(tracker.owner.canonicalName.dropFirst())
615 : tracker.owner.canonicalName,
616 "tracker": tracker.name
617 ],
618 responseType: HomeTrackerTicketsResponse.self
619 )
620
621 return response.user.tracker.tickets.results.compactMap { payload in
622 guard payload.status.isOpen else {
623 return nil
624 }
625 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
626 return nil
627 }
628
629 return HomeAssignedTicket(
630 trackerId: tracker.id,
631 trackerRid: tracker.rid,
632 trackerName: tracker.name,
633 ownerCanonicalName: tracker.owner.canonicalName,
634 ticket: payload.ticketSummary
635 )
636 }
637 }
638
639 private func performTicketStatusUpdate(
640 ticket: HomeAssignedTicket,
641 input: [String: any Sendable]
642 ) async {
643 do {
644 _ = try await client.execute(
645 service: .todo,
646 query: Self.updateTicketStatusMutation,
647 variables: [
648 "trackerId": ticket.trackerId,
649 "ticketId": ticket.ticket.id,
650 "input": input
651 ],
652 responseType: StatusEventResponse.self
653 )
654 assignedTickets.removeAll { $0.id == ticket.id }
655 persistNeedsAttentionSnapshot()
656 } catch {
657 homeLogger.error("Ticket status update failed: \(error, privacy: .public)")
658 }
659 }
660
661 private func persistNeedsAttentionSnapshot() {
662 NeedsAttentionSnapshotStore.save(
663 NeedsAttentionSnapshot(
664 unreadInboxThreads: unreadInboxThreadCount,
665 assignedOpenTickets: assignedTicketsError == nil ? assignedTickets.count : nil,
666 failedBuilds: recentBuildsError == nil
667 ? recentBuilds.filter {
668 switch $0.job.status {
669 case .failed, .timeout:
670 true
671 default:
672 false
673 }
674 }.count
675 : nil,
676 updatedAt: .now
677 )
678 )
679 }
680
681 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
682 jobs.map { job in
683 let repository = primaryRepositoryReference(in: job.manifest)
684 return HomeBuildItem(
685 job: job.jobSummary,
686 repositoryName: repository?.name,
687 repositoryOwner: repository?.ownerCanonicalName
688 )
689 }
690 }
691
692 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
693 buildItems(from: jobs).filter { build in
694 switch build.job.status {
695 case .failed, .timeout:
696 true
697 default:
698 false
699 }
700 }
701 }
702
703 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
704 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
705 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
706 if assigneeCanonical == currentCanonical {
707 return true
708 }
709
710 let assigneeUsername = normalizedUsername(entity.canonicalName)
711 let currentUsername = normalizedUsername(currentUser.username)
712 return assigneeUsername == currentUsername
713 }
714
715 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
716 guard let manifest else { return nil }
717 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
718 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
719 return nil
720 }
721 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
722 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
723 let ownerRange = Range(match.range(at: 1), in: manifest),
724 let nameRange = Range(match.range(at: 2), in: manifest) else {
725 return nil
726 }
727
728 let owner = String(manifest[ownerRange])
729 var name = String(manifest[nameRange])
730 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
731 name.removeSubrange(suffixRange)
732 }
733 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
734 guard !name.isEmpty else { return nil }
735 return (owner, name)
736 }
737
738 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
739 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
740 guard !trimmed.isEmpty else { return trimmed }
741 if trimmed.hasPrefix("~") {
742 return trimmed
743 }
744 return "~\(trimmed)"
745 }
746
747 private nonisolated static func normalizedUsername(_ value: String) -> String {
748 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
749 if trimmed.hasPrefix("~") {
750 return String(trimmed.dropFirst())
751 }
752 return trimmed
753 }
754
755 private struct StatusEventResponse: Decodable, Sendable {
756 struct EventRef: Decodable, Sendable {
757 let eventType: String
758 }
759
760 let updateTicketStatus: EventRef
761 }
762
763 private struct UnassignResponse: Decodable, Sendable {
764 struct EventRef: Decodable, Sendable {
765 let id: Int
766 }
767
768 let unassignUser: EventRef
769 }
770
771 private struct CancelBuildResponse: Decodable, Sendable {
772 struct CancelResult: Decodable, Sendable {
773 let id: Int
774 }
775
776 let cancel: CancelResult
777 }
778}
779struct HomeJobPayload: Decodable, Sendable {
780 let id: Int
781 let created: Date
782 let updated: Date
783 let status: JobStatus
784 let note: String?
785 let tags: [String]
786 let visibility: Visibility?
787 let image: String?
788 let tasks: [JobTaskSummary]
789 let manifest: String?
790
791 nonisolated var jobSummary: JobSummary {
792 JobSummary(
793 id: id,
794 created: created,
795 updated: updated,
796 status: status,
797 note: note,
798 tags: tags,
799 visibility: visibility,
800 image: image,
801 tasks: tasks
802 )
803 }
804}