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}