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}