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}