krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.1: 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 private(set) var failedBuilds: [HomeBuildItem] = []
141 private(set) var assignedTickets: [HomeAssignedTicket] = []
142 private(set) var recentBuilds: [HomeBuildItem] = []
143 private(set) var hasUnreadInboxThreads = false
144 private(set) var isLoadingProjects = false
145 private(set) var isLoadingFailedBuilds = false
146 private(set) var isLoadingAssignedTickets = false
147 private(set) var isLoadingRecentBuilds = false
148 private(set) var failedBuildsError: String?
149 private(set) var assignedTicketsError: String?
150 private(set) var recentBuildsError: String?
151
152 private let currentUser: User
153 private let client: SRHTClient
154 private let projectService: ProjectService
155 private let ticketFetchConcurrencyLimit = 6
156 private let inboxUnreadConcurrencyLimit = 4
157 private let inboxUnreadThreadPreviewLimit = 10
158
159 private static let jobsQuery = """
160 query jobs {
161 jobs {
162 results {
163 id
164 created
165 updated
166 status
167 note
168 tags
169 visibility
170 image
171 tasks { name status }
172 manifest
173 }
174 }
175 }
176 """
177
178 private static let trackersQuery = """
179 query trackers($cursor: Cursor) {
180 trackers(cursor: $cursor) {
181 results {
182 id
183 rid
184 name
185 description
186 visibility
187 updated
188 owner { canonicalName }
189 }
190 cursor
191 }
192 }
193 """
194
195 private static let trackerTicketsQuery = """
196 query tickets($owner: String!, $tracker: String!) {
197 user(username: $owner) {
198 tracker(name: $tracker) {
199 tickets {
200 results {
201 id
202 subject
203 status
204 resolution
205 created
206 submitter { canonicalName }
207 labels { id name backgroundColor foregroundColor }
208 assignees { canonicalName }
209 }
210 }
211 }
212 }
213 }
214 """
215
216 private static let inboxSubscriptionsQuery = """
217 query inboxSubscriptions($cursor: Cursor) {
218 subscriptions(cursor: $cursor) {
219 results {
220 ... on MailingListSubscription {
221 list {
222 id
223 rid
224 name
225 owner { canonicalName }
226 }
227 }
228 }
229 cursor
230 }
231 }
232 """
233
234 private static let inboxListThreadsQuery = """
235 query inboxListThreads($rid: ID!) {
236 list(rid: $rid) {
237 threads {
238 results {
239 updated
240 subject
241 }
242 }
243 }
244 }
245 """
246
247 init(currentUser: User, client: SRHTClient) {
248 self.currentUser = currentUser
249 self.client = client
250 self.projectService = ProjectService(client: client)
251 }
252
253 func loadDashboard() async {
254 isLoadingProjects = true
255 isLoadingFailedBuilds = true
256 isLoadingAssignedTickets = true
257 isLoadingRecentBuilds = true
258 failedBuildsError = nil
259 assignedTicketsError = nil
260 recentBuildsError = nil
261
262 async let projectsTask = loadProjects()
263 async let jobsTask = loadRecentJobs()
264 async let assignedTicketsTask = loadAssignedTickets()
265 async let inboxUnreadTask = loadInboxUnreadState()
266
267 let projectsResult = await projectsTask
268 switch projectsResult {
269 case .success(let projects):
270 self.projects = projects
271 case .failure:
272 self.projects = []
273 }
274 isLoadingProjects = false
275
276 let recentJobsResult = await jobsTask
277
278 switch recentJobsResult {
279 case .success(let recentJobs):
280 let buildItems = Self.buildItems(from: recentJobs)
281 self.recentBuilds = buildItems
282 self.failedBuilds = Self.failedBuilds(from: buildItems)
283 self.failedBuildsError = nil
284 self.recentBuildsError = nil
285 case .failure(let error):
286 self.recentBuilds = []
287 self.failedBuilds = []
288 self.failedBuildsError = error.userFacingMessage
289 self.recentBuildsError = error.userFacingMessage
290 }
291 isLoadingFailedBuilds = false
292 isLoadingRecentBuilds = false
293
294 let assignedTicketsResult = await assignedTicketsTask
295
296 switch assignedTicketsResult {
297 case .success(let assignedTickets):
298 self.assignedTickets = assignedTickets
299 self.assignedTicketsError = nil
300 case .failure(let error):
301 self.assignedTickets = []
302 self.assignedTicketsError = error.userFacingMessage
303 }
304 isLoadingAssignedTickets = false
305
306 hasUnreadInboxThreads = (await inboxUnreadTask) ?? false
307 }
308
309 private func loadProjects() async -> Result<[Project], Error> {
310 do {
311 return .success(try await projectService.fetchProjects())
312 } catch {
313 return .failure(error)
314 }
315 }
316
317 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
318 do {
319 let response = try await client.execute(
320 service: .builds,
321 query: Self.jobsQuery,
322 responseType: HomeJobsResponse.self
323 )
324 return .success(response.jobs.results)
325 } catch {
326 return .failure(error)
327 }
328 }
329
330 private func loadInboxUnreadState() async -> Bool? {
331 do {
332 return try await fetchHasUnreadInboxThreads()
333 } catch {
334 return nil
335 }
336 }
337
338 private func fetchHasUnreadInboxThreads() async throws -> Bool {
339 let mailingLists = try await fetchInboxMailingLists()
340 guard !mailingLists.isEmpty else { return false }
341
342 var startIndex = mailingLists.startIndex
343 while startIndex < mailingLists.endIndex {
344 let endIndex = mailingLists.index(
345 startIndex,
346 offsetBy: inboxUnreadConcurrencyLimit,
347 limitedBy: mailingLists.endIndex
348 ) ?? mailingLists.endIndex
349 let batch = Array(mailingLists[startIndex..<endIndex])
350
351 let batchHasUnread = await withTaskGroup(of: Bool.self) { group in
352 for mailingList in batch {
353 group.addTask {
354 (try? await self.fetchHasUnreadThreads(for: mailingList)) ?? false
355 }
356 }
357
358 for await hasUnread in group {
359 if hasUnread {
360 group.cancelAll()
361 return true
362 }
363 }
364 return false
365 }
366
367 if batchHasUnread {
368 return true
369 }
370
371 startIndex = endIndex
372 }
373
374 return false
375 }
376
377 private func fetchInboxMailingLists() async throws -> [InboxMailingListReference] {
378 var subscriptions: [HomeInboxSubscription] = []
379 var cursor: String?
380
381 while true {
382 var variables: [String: any Sendable] = [:]
383 if let cursor {
384 variables["cursor"] = cursor
385 }
386
387 let response = try await client.execute(
388 service: .lists,
389 query: Self.inboxSubscriptionsQuery,
390 variables: variables.isEmpty ? nil : variables,
391 responseType: HomeInboxSubscriptionsResponse.self
392 )
393
394 subscriptions.append(contentsOf: response.subscriptions.results)
395 guard let nextCursor = response.subscriptions.cursor else {
396 break
397 }
398 cursor = nextCursor
399 }
400
401 var seen = Set<String>()
402 return subscriptions.compactMap(\.list).filter { seen.insert($0.rid).inserted }
403 }
404
405 private func fetchHasUnreadThreads(for mailingList: InboxMailingListReference) async throws -> Bool {
406 let response = try await client.execute(
407 service: .lists,
408 query: Self.inboxListThreadsQuery,
409 variables: ["rid": mailingList.rid],
410 responseType: HomeInboxListThreadsResponse.self
411 )
412
413 return response.list.threads.results.prefix(inboxUnreadThreadPreviewLimit).contains { thread in
414 let normalizedSubject = thread.subject
415 .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
416 .trimmingCharacters(in: .whitespacesAndNewlines)
417 .replacingOccurrences(of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, with: "", options: [.regularExpression, .caseInsensitive])
418 .lowercased()
419 let threadID = "\(mailingList.rid)#\(normalizedSubject)"
420 return InboxReadStateStore.isUnread(threadID: threadID, lastActivityAt: thread.updated)
421 }
422 }
423
424 private func loadAssignedTickets() async -> Result<[HomeAssignedTicket], Error> {
425 do {
426 let trackers = try await fetchAllTrackers()
427 let tickets = try await fetchAssignedTickets(for: trackers)
428 .sorted { $0.ticket.created > $1.ticket.created }
429 return .success(tickets)
430 } catch {
431 return .failure(error)
432 }
433 }
434
435 private func fetchAllTrackers() async throws -> [TrackerSummary] {
436 var allTrackers: [TrackerSummary] = []
437 var cursor: String?
438
439 while true {
440 var variables: [String: any Sendable] = [:]
441 if let cursor {
442 variables["cursor"] = cursor
443 }
444
445 let response = try await client.execute(
446 service: .todo,
447 query: Self.trackersQuery,
448 variables: variables.isEmpty ? nil : variables,
449 responseType: HomeTrackersResponse.self
450 )
451
452 allTrackers.append(contentsOf: response.trackers.results)
453 guard let nextCursor = response.trackers.cursor else {
454 break
455 }
456 cursor = nextCursor
457 }
458
459 return allTrackers
460 }
461
462 private func fetchAssignedTickets(for trackers: [TrackerSummary]) async throws -> [HomeAssignedTicket] {
463 guard !trackers.isEmpty else { return [] }
464
465 var assignedTickets: [HomeAssignedTicket] = []
466 var startIndex = trackers.startIndex
467
468 while startIndex < trackers.endIndex {
469 let endIndex = trackers.index(startIndex, offsetBy: ticketFetchConcurrencyLimit, limitedBy: trackers.endIndex) ?? trackers.endIndex
470 let batch = Array(trackers[startIndex..<endIndex])
471
472 let batchTickets = try await withThrowingTaskGroup(of: [HomeAssignedTicket].self) { group in
473 for tracker in batch {
474 group.addTask {
475 try await self.fetchAssignedTickets(for: tracker)
476 }
477 }
478
479 var ticketsForBatch: [HomeAssignedTicket] = []
480 for try await tickets in group {
481 ticketsForBatch.append(contentsOf: tickets)
482 }
483 return ticketsForBatch
484 }
485
486 assignedTickets.append(contentsOf: batchTickets)
487 startIndex = endIndex
488 }
489
490 return assignedTickets
491 }
492
493 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
494 let response = try await client.execute(
495 service: .todo,
496 query: Self.trackerTicketsQuery,
497 variables: [
498 "owner": tracker.owner.canonicalName.hasPrefix("~")
499 ? String(tracker.owner.canonicalName.dropFirst())
500 : tracker.owner.canonicalName,
501 "tracker": tracker.name
502 ],
503 responseType: HomeTrackerTicketsResponse.self
504 )
505
506 return response.user.tracker.tickets.results.compactMap { payload in
507 guard payload.status.isOpen else {
508 return nil
509 }
510 guard payload.assignees.contains(where: { Self.matchesCurrentUserAssignee($0, currentUser: currentUser) }) else {
511 return nil
512 }
513
514 return HomeAssignedTicket(
515 trackerId: tracker.id,
516 trackerRid: tracker.rid,
517 trackerName: tracker.name,
518 ownerCanonicalName: tracker.owner.canonicalName,
519 ticket: payload.ticketSummary
520 )
521 }
522 }
523
524 nonisolated static func buildItems(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
525 jobs.map { job in
526 let repository = primaryRepositoryReference(in: job.manifest)
527 return HomeBuildItem(
528 job: job.jobSummary,
529 repositoryName: repository?.name,
530 repositoryOwner: repository?.ownerCanonicalName
531 )
532 }
533 }
534
535 nonisolated static func failedBuilds(from builds: [HomeBuildItem]) -> [HomeBuildItem] {
536 builds.filter { build in
537 switch build.job.status {
538 case .failed, .timeout:
539 true
540 default:
541 false
542 }
543 }
544 }
545
546 nonisolated static func failedBuilds(from jobs: [HomeJobPayload]) -> [HomeBuildItem] {
547 failedBuilds(from: buildItems(from: jobs))
548 }
549
550 nonisolated static func matchesCurrentUserAssignee(_ entity: Entity, currentUser: User) -> Bool {
551 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
552 let currentCanonical = normalizedCanonicalName(currentUser.canonicalName)
553 if assigneeCanonical == currentCanonical {
554 return true
555 }
556
557 let assigneeUsername = normalizedUsername(entity.canonicalName)
558 let currentUsername = normalizedUsername(currentUser.username)
559 return assigneeUsername == currentUsername
560 }
561
562 nonisolated static func primaryRepositoryReference(in manifest: String?) -> (ownerCanonicalName: String, name: String)? {
563 guard let manifest else { return nil }
564 let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(?:git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
565 guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
566 return nil
567 }
568 let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
569 guard let match = regex.firstMatch(in: manifest, options: [], range: nsRange),
570 let ownerRange = Range(match.range(at: 1), in: manifest),
571 let nameRange = Range(match.range(at: 2), in: manifest) else {
572 return nil
573 }
574
575 let owner = String(manifest[ownerRange])
576 var name = String(manifest[nameRange])
577 if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
578 name.removeSubrange(suffixRange)
579 }
580 name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
581 guard !name.isEmpty else { return nil }
582 return (owner, name)
583 }
584
585 private nonisolated static func normalizedCanonicalName(_ value: String) -> String {
586 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
587 guard !trimmed.isEmpty else { return trimmed }
588 if trimmed.hasPrefix("~") {
589 return trimmed
590 }
591 return "~\(trimmed)"
592 }
593
594 private nonisolated static func normalizedUsername(_ value: String) -> String {
595 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
596 if trimmed.hasPrefix("~") {
597 return String(trimmed.dropFirst())
598 }
599 return trimmed
600 }
601}
602struct HomeJobPayload: Decodable, Sendable {
603 let id: Int
604 let created: Date
605 let updated: Date
606 let status: JobStatus
607 let note: String?
608 let tags: [String]
609 let visibility: Visibility?
610 let image: String?
611 let tasks: [JobTaskSummary]
612 let manifest: String?
613
614 nonisolated var jobSummary: JobSummary {
615 JobSummary(
616 id: id,
617 created: created,
618 updated: updated,
619 status: status,
620 note: note,
621 tags: tags,
622 visibility: visibility,
623 image: image,
624 tasks: tasks
625 )
626 }
627}