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