krz/hutch

an ios client for sourcehut

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

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