krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v3.6.0: Hutch/Views/Repositories/RepositoryListViewModel.swift · raw

  1import Foundation
  2
  3enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
  4    case git
  5    case hg
  6
  7    var id: String { rawValue }
  8
  9    var service: SRHTService {
 10        switch self {
 11        case .git: .git
 12        case .hg: .hg
 13        }
 14    }
 15
 16    var displayName: String {
 17        switch self {
 18        case .git: "Git"
 19        case .hg: "Mercurial"
 20        }
 21    }
 22}
 23
 24/// View model for the repository list screen.
 25@Observable
 26@MainActor
 27final class RepositoryListViewModel {
 28    private static let searchHistoryScopeID = "repositories"
 29
 30    private(set) var repositories: [RepositorySummary] = []
 31    private(set) var latestBuildStatuses: [String: RepositoryBuildStatus] = [:]
 32    private(set) var recentSearches: [ScopedSearchHistoryEntry]
 33    private(set) var isLoading = false
 34    private(set) var isLoadingMore = false
 35    private(set) var isRefreshing = false
 36    var error: String?
 37
 38    var searchText = ""
 39
 40    private(set) var cursor: String?
 41    private(set) var hasMore = false
 42    private(set) var isSearching = false
 43    private(set) var isCreatingRepository = false
 44    private(set) var hasLoadedSearchIndex = false
 45    private var searchIndex: [RepositorySummary] = []
 46    private let client: SRHTClient
 47    private let defaults: UserDefaults
 48    private var buildStatusTask: Task<Void, Never>?
 49    private var lastBuildStatusRefresh: Date?
 50
 51    private static let gitCacheKey = "git.repositories"
 52    private static let hgCacheKey = "hg.repositories"
 53    private static let buildsCacheKey = "builds.repository-status"
 54    private static let minimumRemoteSearchLength = 3
 55
 56    init(client: SRHTClient, defaults: UserDefaults = .standard) {
 57        self.client = client
 58        self.defaults = defaults
 59        self.recentSearches = ScopedSearchHistoryStore.load(
 60            scopeID: Self.searchHistoryScopeID,
 61            defaults: defaults
 62        )
 63    }
 64
 65    // MARK: - Queries
 66
 67    private static let gitQuery = """
 68    query repositories($cursor: Cursor, $filter: Filter) {
 69        repositories(cursor: $cursor, filter: $filter) {
 70            results {
 71                id
 72                rid
 73                name
 74                description
 75                visibility
 76                updated
 77                owner { canonicalName }
 78                HEAD { name }
 79            }
 80            cursor
 81        }
 82    }
 83    """
 84
 85    private static let hgQuery = """
 86    query repositories($cursor: Cursor) {
 87        repositories(cursor: $cursor) {
 88            results {
 89                id
 90                rid
 91                name
 92                description
 93                visibility
 94                updated
 95                owner { canonicalName }
 96                tip { branch }
 97            }
 98            cursor
 99        }
100    }
101    """
102
103    private static let createRepositoryMutation = """
104    mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
105        createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
106            id
107            rid
108            name
109            description
110            visibility
111            updated
112            owner { canonicalName }
113        }
114    }
115    """
116
117    private static let createHgRepositoryMutation = """
118    mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
119        createRepository(name: $name, visibility: $visibility, description: $description) {
120            id
121            rid
122            name
123            description
124            visibility
125            updated
126            owner { canonicalName }
127            tip { branch }
128        }
129    }
130    """
131
132    private static let buildsQuery = """
133    query jobs($cursor: Cursor) {
134        jobs(cursor: $cursor) {
135            results {
136                id
137                created
138                status
139                manifest
140            }
141            cursor
142        }
143    }
144    """
145
146    // MARK: - Public API
147
148    /// Fetch the first page of repositories. Shows cached data instantly if available,
149    /// then refreshes from the network in the background.
150    /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
151    /// - Parameter forceRefresh: When true, bypass the build-status TTL (e.g. pull-to-refresh).
152    func loadRepositories(search: String? = nil, forceRefresh: Bool = false) async {
153        let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
154        let isSearch = !query.isEmpty
155
156        // Only use cache for non-search, initial loads
157        if !isSearch, repositories.isEmpty {
158            await loadFromCache()
159        }
160
161        // During search, never show the full-screen loading overlay (which
162        // would remove the List and dismiss the keyboard). Use "refreshing"
163        // instead so the list stays in the hierarchy.
164        if isSearch {
165            isRefreshing = true
166            isSearching = true
167        } else if repositories.isEmpty {
168            isLoading = true
169            isSearching = false
170        } else {
171            isRefreshing = true
172            isSearching = false
173        }
174        error = nil
175        cursor = nil
176        hasMore = false
177
178        do {
179            var filteredResults: [RepositorySummary]
180
181            if isSearch {
182                if hasLoadedSearchIndex || repositories.isEmpty == false {
183                    filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
184                } else if Self.shouldRefreshSearchIndex(for: query) {
185                    let repositories = try await fetchAllRepositories(useCache: true)
186                    updateSearchIndex(with: repositories)
187                    filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
188                } else {
189                    filteredResults = []
190                }
191            } else {
192                let repositories = try await fetchAllRepositories(useCache: true)
193                updateSearchIndex(with: repositories)
194                filteredResults = repositories
195            }
196
197            repositories = filteredResults.sorted(by: repositorySortOrder)
198            scheduleBuildStatusRefresh(force: forceRefresh)
199        } catch {
200            // Only show error if we have no cached data to fall back on
201            if repositories.isEmpty {
202                self.error = error.userFacingMessage
203            }
204        }
205
206        isLoading = false
207        isRefreshing = false
208    }
209
210    /// Load the next page if available. Called when the user scrolls near the end.
211    /// Note: Pagination is disabled during search (client-side filtering).
212    func loadMoreIfNeeded(currentItem: RepositorySummary) async {
213        _ = currentItem
214    }
215
216    /// Remove a repository from the local list (e.g. after deletion).
217    func removeRepository(id: Int) {
218        repositories.removeAll { $0.id == id }
219    }
220
221    func updateRepository(_ repository: RepositorySummary) {
222        if let index = repositories.firstIndex(where: { $0.id == repository.id }) {
223            repositories[index] = repository
224            repositories.sort(by: repositorySortOrder)
225        }
226
227        let updatedRepositories = repositoriesForSearchIndex
228            .filter { $0.id != repository.id } + [repository]
229        updateSearchIndex(with: updatedRepositories)
230    }
231
232    func createRepository(
233        service: RepositoryCreationService,
234        name: String,
235        description: String,
236        visibility: Visibility,
237        cloneURL: String
238    ) async -> RepositorySummary? {
239        guard !isCreatingRepository else { return nil }
240
241        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
242        guard !trimmedName.isEmpty else {
243            error = "Enter a repository name."
244            return nil
245        }
246
247        isCreatingRepository = true
248        error = nil
249        defer { isCreatingRepository = false }
250
251        var variables: [String: any Sendable] = [
252            "name": trimmedName,
253            "visibility": visibility.rawValue
254        ]
255        let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
256        if !trimmedDescription.isEmpty {
257            variables["description"] = trimmedDescription
258        }
259        let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
260        if !trimmedCloneURL.isEmpty {
261            variables["cloneUrl"] = trimmedCloneURL
262        }
263
264        do {
265            let repository: RepositorySummary
266            switch service {
267            case .git:
268                let result = try await client.execute(
269                    service: .git,
270                    query: Self.createRepositoryMutation,
271                    variables: variables,
272                    responseType: CreateRepositoryResponse.self
273                )
274                repository = result.createRepository
275            case .hg:
276                variables.removeValue(forKey: "cloneUrl")
277                let result = try await client.execute(
278                    service: .hg,
279                    query: Self.createHgRepositoryMutation,
280                    variables: variables,
281                    responseType: CreateHGRepositoryResponse.self
282                )
283                repository = result.createRepository.repositorySummary(service: .hg)
284            }
285            await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories"))
286            await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
287            repositories.insert(repository, at: 0)
288            insertIntoSearchIndex(repository)
289            scheduleBuildStatusRefresh()
290            return repository
291        } catch {
292            self.error = repositoryCreationErrorMessage(for: error)
293            return nil
294        }
295    }
296
297    private func repositoryCreationErrorMessage(for error: Error) -> String {
298        "Couldn’t create the repository. \(error.userFacingMessage)"
299    }
300
301    /// Fetch ALL repositories by paginating through all available pages.
302    /// Used for search functionality to ensure we search through the complete dataset.
303    private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
304        async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
305        async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
306        return try await gitRepositories + hgRepositories
307    }
308
309    /// Reset search state and reload all repositories
310    func resetSearch() {
311        repositories = []
312        cursor = nil
313        hasMore = false
314        isSearching = false
315    }
316
317    func recordRecentSearch(_ query: String) {
318        ScopedSearchHistoryStore.record(
319            query: query,
320            scopeID: Self.searchHistoryScopeID,
321            defaults: defaults
322        )
323        recentSearches = ScopedSearchHistoryStore.load(
324            scopeID: Self.searchHistoryScopeID,
325            defaults: defaults
326        )
327    }
328
329    func clearRecentSearches() {
330        ScopedSearchHistoryStore.clear(
331            scopeID: Self.searchHistoryScopeID,
332            defaults: defaults
333        )
334        recentSearches = []
335    }
336
337    // MARK: - Private
338
339    /// Page shape matching the GraphQL response without generic constraints that
340    /// conflict with strict concurrency when used from a @MainActor context.
341    private struct Page: Decodable, Sendable {
342        let results: [RepositoryPayload]
343        let cursor: String?
344    }
345
346    private struct RepositoriesResponse: Decodable, Sendable {
347        let repositories: Page?
348    }
349
350    private struct CreateRepositoryResponse: Decodable, Sendable {
351        let createRepository: RepositorySummary
352    }
353
354    private struct CreateHGRepositoryResponse: Decodable, Sendable {
355        let createRepository: HGRepositoryPayload
356    }
357
358    private struct BuildJobsResponse: Decodable, Sendable {
359        let jobs: BuildJobsPage
360    }
361
362    private struct BuildJobsPage: Decodable, Sendable {
363        let results: [BuildStatusPayload]
364        let cursor: String?
365    }
366
367    private struct BuildStatusPayload: Decodable, Sendable {
368        let id: Int
369        let created: Date
370        let status: JobStatus
371        let manifest: String?
372    }
373
374    private struct HGPage: Decodable, Sendable {
375        let results: [HGRepositoryPayload]
376        let cursor: String?
377    }
378
379    private struct HGRepositoriesResponse: Decodable, Sendable {
380        let repositories: HGPage?
381    }
382
383    private static let emptyPage = Page(results: [], cursor: nil)
384
385    private struct RepositoryPayload: Decodable, Sendable {
386        let id: Int
387        let rid: String
388        let name: String
389        let description: String?
390        let visibility: Visibility
391        let updated: Date
392        let owner: Entity
393        let head: Reference?
394
395        enum CodingKeys: String, CodingKey {
396            case id, rid, name, description, visibility, updated, owner
397            case head = "HEAD"
398        }
399
400        func repositorySummary(service: SRHTService) -> RepositorySummary {
401            RepositorySummary(
402                fields: .init(
403                    id: id,
404                    rid: rid,
405                    service: service,
406                    name: name,
407                    description: description,
408                    visibility: visibility,
409                    updated: updated,
410                    owner: owner,
411                    head: head
412                )
413            )
414        }
415    }
416
417    private struct HGRepositoryPayload: Decodable, Sendable {
418        let id: Int
419        let rid: String
420        let name: String
421        let description: String?
422        let visibility: Visibility
423        let updated: Date
424        let owner: Entity
425        let tip: HGTipReference?
426
427        func repositorySummary(service: SRHTService) -> RepositorySummary {
428            RepositorySummary(
429                fields: .init(
430                    id: id,
431                    rid: rid,
432                    service: service,
433                    name: name,
434                    description: description,
435                    visibility: visibility,
436                    updated: updated,
437                    owner: owner,
438                    head: tip.map { Reference(name: $0.branch, target: nil) }
439                )
440            )
441        }
442    }
443
444    private struct HGTipReference: Decodable, Sendable {
445        let branch: String
446    }
447
448    private var repositoriesForSearchIndex: [RepositorySummary] {
449        searchIndex
450    }
451
452    func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
453        latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
454    }
455
456    private func fetchPage(
457        service: SRHTService,
458        cursor: String?,
459        search: String? = nil,
460        useCache: Bool
461    ) async throws -> Page {
462        var variables: [String: any Sendable] = [:]
463        if let cursor {
464            variables["cursor"] = cursor
465        }
466        let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
467        if !trimmed.isEmpty {
468            variables["filter"] = ["search": trimmed] as [String: any Sendable]
469        }
470
471        if useCache && cursor == nil {
472            if service == .hg {
473                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
474                let cached = try await client.executeCached(
475                    service: service,
476                    query: Self.hgQuery,
477                    variables: hgVariables,
478                    responseType: HGRepositoriesResponse.self,
479                    cacheKey: cacheKey(for: service),
480                    resourceType: .repositoryList,
481                    ttl: APICacheTTLs.repositoryList,
482                    policy: .cacheFirstThenRefresh
483                )
484                let result = cached.value
485                return Page(
486                    results: result.repositories?.results.map {
487                        RepositoryPayload(
488                            id: $0.id,
489                            rid: $0.rid,
490                            name: $0.name,
491                            description: $0.description,
492                            visibility: $0.visibility,
493                            updated: $0.updated,
494                            owner: $0.owner,
495                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
496                        )
497                    } ?? [],
498                    cursor: result.repositories?.cursor
499                )
500            }
501            let cached = try await client.executeCached(
502                service: service,
503                query: Self.gitQuery,
504                variables: variables.isEmpty ? nil : variables,
505                responseType: RepositoriesResponse.self,
506                cacheKey: cacheKey(for: service),
507                resourceType: .repositoryList,
508                ttl: APICacheTTLs.repositoryList,
509                policy: .cacheFirstThenRefresh
510            )
511            return cached.value.repositories ?? Self.emptyPage
512        } else {
513            if service == .hg {
514                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
515                let result = try await client.execute(
516                    service: service,
517                    query: Self.hgQuery,
518                    variables: hgVariables,
519                    responseType: HGRepositoriesResponse.self
520                )
521                return Page(
522                    results: result.repositories?.results.map {
523                        RepositoryPayload(
524                            id: $0.id,
525                            rid: $0.rid,
526                            name: $0.name,
527                            description: $0.description,
528                            visibility: $0.visibility,
529                            updated: $0.updated,
530                            owner: $0.owner,
531                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
532                        )
533                    } ?? [],
534                    cursor: result.repositories?.cursor
535                )
536            }
537            let result = try await client.execute(
538                service: service,
539                query: Self.gitQuery,
540                variables: variables.isEmpty ? nil : variables,
541                responseType: RepositoriesResponse.self
542            )
543            return result.repositories ?? Self.emptyPage
544        }
545    }
546
547    private func loadFromCache() async {
548        var persistedRepositories: [RepositorySummary] = []
549        for service in [SRHTService.git, .hg] {
550            if let data = await client.cachedPayload(forKey: cacheKey(for: service)) {
551                persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service))
552            }
553        }
554        let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories
555        if !cachedRepositories.isEmpty {
556            let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
557            repositories = sortedRepositories
558            updateSearchIndex(with: sortedRepositories)
559            scheduleBuildStatusRefresh()
560        }
561    }
562
563    private func legacyCachedRepositories() -> [RepositorySummary] {
564        [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
565            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
566            return Self.decodeCachedRepositories(data, service: service)
567        }
568    }
569
570    private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] {
571        let decoder = JSONDecoder()
572        decoder.dateDecodingStrategy = .srhtFlexible
573        switch service {
574        case .git:
575            if let response = try? decoder.decode(
576                GraphQLResponse<RepositoriesResponse>.self,
577                from: data
578            ), let repos = response.data?.repositories {
579                return repos.results.map { $0.repositorySummary(service: service) }
580            }
581        case .hg:
582            if let response = try? decoder.decode(
583                GraphQLResponse<HGRepositoriesResponse>.self,
584                from: data
585            ), let repos = response.data?.repositories {
586                return repos.results.map { $0.repositorySummary(service: service) }
587            }
588        default:
589            break
590        }
591        return []
592    }
593
594    private func scheduleBuildStatusRefresh(force: Bool = false) {
595        // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
596        // passes force: true to bypass this check.
597        if !force, let last = lastBuildStatusRefresh,
598           Date().timeIntervalSince(last) < 120 {
599            return
600        }
601        let repositoriesSnapshot = repositories
602        buildStatusTask?.cancel()
603        buildStatusTask = Task { [weak self] in
604            guard let self else { return }
605            await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
606        }
607    }
608
609    private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
610        let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
611        guard !targetKeys.isEmpty else {
612            await MainActor.run {
613                latestBuildStatuses = [:]
614            }
615            return
616        }
617
618        var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
619        var cursor: String?
620        var shouldUseCache = true
621
622        do {
623            while !Task.isCancelled {
624                let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
625                shouldUseCache = false
626
627                for job in page.results {
628                    let jobStatus = Self.repositoryBuildStatus(for: job.status)
629                    guard let manifest = job.manifest else { continue }
630
631                    for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
632                        let existing = resolvedStatuses[key]
633                        if existing == nil || existing!.0 < job.created {
634                            resolvedStatuses[key] = (job.created, jobStatus)
635                        }
636                    }
637                }
638
639                if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
640                    break
641                }
642                cursor = page.cursor
643            }
644
645            let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
646                result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
647            }
648
649            await MainActor.run {
650                guard repositories == self.repositories else { return }
651                latestBuildStatuses = finalStatuses
652                lastBuildStatusRefresh = Date()
653            }
654        } catch {
655            // Build status is auxiliary data for the list. Leave the default gray state on failure.
656        }
657    }
658
659    private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
660        var variables: [String: any Sendable] = [:]
661        if let cursor {
662            variables["cursor"] = cursor
663        }
664
665        if useCache && cursor == nil {
666            let cached = try await client.executeCached(
667                service: .builds,
668                query: Self.buildsQuery,
669                variables: variables.isEmpty ? nil : variables,
670                responseType: BuildJobsResponse.self,
671                cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"),
672                resourceType: .buildList,
673                ttl: APICacheTTLs.activeBuild,
674                policy: .cacheFirstThenRefresh
675            )
676            return cached.value.jobs
677        }
678
679        let result = try await client.execute(
680            service: .builds,
681            query: Self.buildsQuery,
682            variables: variables.isEmpty ? nil : variables,
683            responseType: BuildJobsResponse.self
684        )
685        return result.jobs
686    }
687
688    private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
689        var allRepositories: [RepositorySummary] = []
690        var currentCursor: String? = nil
691
692        while true {
693            let page = try await fetchPage(
694                service: service,
695                cursor: currentCursor,
696                search: nil,
697                useCache: useCache && currentCursor == nil
698            )
699            allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
700            guard let nextCursor = page.cursor else { break }
701            currentCursor = nextCursor
702        }
703
704        return allRepositories
705    }
706
707    private func cacheKey(for service: SRHTService) -> String {
708        switch service {
709        case .git:
710            APICacheKeys.repositories(service: .git)
711        case .hg:
712            APICacheKeys.repositories(service: .hg)
713        default:
714            APICacheKeys.repositories(service: service)
715        }
716    }
717
718    private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
719        if lhs.updated == rhs.updated {
720            if lhs.service == rhs.service {
721                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
722            }
723            return lhs.service.rawValue < rhs.service.rawValue
724        }
725        return lhs.updated > rhs.updated
726    }
727
728    private func updateSearchIndex(with repositories: [RepositorySummary]) {
729        searchIndex = repositories.sorted(by: repositorySortOrder)
730        hasLoadedSearchIndex = !searchIndex.isEmpty
731    }
732
733    private func insertIntoSearchIndex(_ repository: RepositorySummary) {
734        let updatedRepositories = (repositoriesForSearchIndex + [repository])
735            .uniqued(on: \.id)
736            .sorted(by: repositorySortOrder)
737        updateSearchIndex(with: updatedRepositories)
738    }
739
740    static func shouldRefreshSearchIndex(for query: String) -> Bool {
741        query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
742    }
743
744    static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
745        let lowercasedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
746        guard !lowercasedQuery.isEmpty else { return repositories }
747
748        return repositories.filter { repo in
749            repo.name.lowercased().contains(lowercasedQuery) ||
750            repo.owner.canonicalName.lowercased().contains(lowercasedQuery) ||
751            repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false ||
752            repo.description?.lowercased().contains(lowercasedQuery) ?? false
753        }
754    }
755
756    nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
757        buildStatusCacheKey(
758            service: repository.service,
759            ownerCanonicalName: repository.owner.canonicalName,
760            repositoryName: repository.name
761        )
762    }
763
764    nonisolated static func buildStatusCacheKey(
765        service: SRHTService,
766        ownerCanonicalName: String,
767        repositoryName: String
768    ) -> String {
769        "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
770    }
771
772    nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
773        switch jobStatus {
774        case .success:
775            .success
776        case .pending, .queued, .running:
777            .running
778        case .failed, .cancelled, .timeout:
779            .failed
780        }
781    }
782
783    nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
784        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
785        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
786            return []
787        }
788
789        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
790        return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
791            guard
792                let serviceRange = Range(match.range(at: 1), in: manifest),
793                let ownerRange = Range(match.range(at: 2), in: manifest),
794                let nameRange = Range(match.range(at: 3), in: manifest)
795            else {
796                return
797            }
798
799            let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
800            let owner = String(manifest[ownerRange]).lowercased()
801            var name = String(manifest[nameRange]).lowercased()
802
803            if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
804                name.removeSubrange(suffixRange)
805            }
806            name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
807            if !name.isEmpty {
808                result.insert(buildStatusCacheKey(
809                    service: service,
810                    ownerCanonicalName: owner,
811                    repositoryName: name
812                ))
813            }
814        }
815    }
816}
817
818private extension Array {
819    func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
820        var seenIDs: Set<ID> = []
821        return filter { element in
822            seenIDs.insert(element[keyPath: keyPath]).inserted
823        }
824    }
825}