krz/hutch

an ios client for sourcehut

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

v3.1.5: 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            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            repositories.insert(repository, at: 0)
286            insertIntoSearchIndex(repository)
287            scheduleBuildStatusRefresh()
288            return repository
289        } catch {
290            self.error = repositoryCreationErrorMessage(for: error)
291            return nil
292        }
293    }
294
295    private func repositoryCreationErrorMessage(for error: Error) -> String {
296        "Couldn’t create the repository. \(error.userFacingMessage)"
297    }
298
299    /// Fetch ALL repositories by paginating through all available pages.
300    /// Used for search functionality to ensure we search through the complete dataset.
301    private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
302        async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
303        async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
304        return try await gitRepositories + hgRepositories
305    }
306
307    /// Reset search state and reload all repositories
308    func resetSearch() {
309        repositories = []
310        cursor = nil
311        hasMore = false
312        isSearching = false
313    }
314
315    func recordRecentSearch(_ query: String) {
316        ScopedSearchHistoryStore.record(
317            query: query,
318            scopeID: Self.searchHistoryScopeID,
319            defaults: defaults
320        )
321        recentSearches = ScopedSearchHistoryStore.load(
322            scopeID: Self.searchHistoryScopeID,
323            defaults: defaults
324        )
325    }
326
327    func clearRecentSearches() {
328        ScopedSearchHistoryStore.clear(
329            scopeID: Self.searchHistoryScopeID,
330            defaults: defaults
331        )
332        recentSearches = []
333    }
334
335    // MARK: - Private
336
337    /// Page shape matching the GraphQL response without generic constraints that
338    /// conflict with strict concurrency when used from a @MainActor context.
339    private struct Page: Decodable, Sendable {
340        let results: [RepositoryPayload]
341        let cursor: String?
342    }
343
344    private struct RepositoriesResponse: Decodable, Sendable {
345        let repositories: Page?
346    }
347
348    private struct CreateRepositoryResponse: Decodable, Sendable {
349        let createRepository: RepositorySummary
350    }
351
352    private struct CreateHGRepositoryResponse: Decodable, Sendable {
353        let createRepository: HGRepositoryPayload
354    }
355
356    private struct BuildJobsResponse: Decodable, Sendable {
357        let jobs: BuildJobsPage
358    }
359
360    private struct BuildJobsPage: Decodable, Sendable {
361        let results: [BuildStatusPayload]
362        let cursor: String?
363    }
364
365    private struct BuildStatusPayload: Decodable, Sendable {
366        let id: Int
367        let created: Date
368        let status: JobStatus
369        let manifest: String?
370    }
371
372    private struct HGPage: Decodable, Sendable {
373        let results: [HGRepositoryPayload]
374        let cursor: String?
375    }
376
377    private struct HGRepositoriesResponse: Decodable, Sendable {
378        let repositories: HGPage?
379    }
380
381    private static let emptyPage = Page(results: [], cursor: nil)
382
383    private struct RepositoryPayload: Decodable, Sendable {
384        let id: Int
385        let rid: String
386        let name: String
387        let description: String?
388        let visibility: Visibility
389        let updated: Date
390        let owner: Entity
391        let head: Reference?
392
393        enum CodingKeys: String, CodingKey {
394            case id, rid, name, description, visibility, updated, owner
395            case head = "HEAD"
396        }
397
398        func repositorySummary(service: SRHTService) -> RepositorySummary {
399            RepositorySummary(
400                fields: .init(
401                    id: id,
402                    rid: rid,
403                    service: service,
404                    name: name,
405                    description: description,
406                    visibility: visibility,
407                    updated: updated,
408                    owner: owner,
409                    head: head
410                )
411            )
412        }
413    }
414
415    private struct HGRepositoryPayload: Decodable, Sendable {
416        let id: Int
417        let rid: String
418        let name: String
419        let description: String?
420        let visibility: Visibility
421        let updated: Date
422        let owner: Entity
423        let tip: HGTipReference?
424
425        func repositorySummary(service: SRHTService) -> RepositorySummary {
426            RepositorySummary(
427                fields: .init(
428                    id: id,
429                    rid: rid,
430                    service: service,
431                    name: name,
432                    description: description,
433                    visibility: visibility,
434                    updated: updated,
435                    owner: owner,
436                    head: tip.map { Reference(name: $0.branch, target: nil) }
437                )
438            )
439        }
440    }
441
442    private struct HGTipReference: Decodable, Sendable {
443        let branch: String
444    }
445
446    private var repositoriesForSearchIndex: [RepositorySummary] {
447        searchIndex
448    }
449
450    func latestBuildStatus(for repository: RepositorySummary) -> RepositoryBuildStatus {
451        latestBuildStatuses[Self.buildStatusCacheKey(for: repository)] ?? RepositoryBuildStatus.none
452    }
453
454    private func fetchPage(
455        service: SRHTService,
456        cursor: String?,
457        search: String? = nil,
458        useCache: Bool
459    ) async throws -> Page {
460        var variables: [String: any Sendable] = [:]
461        if let cursor {
462            variables["cursor"] = cursor
463        }
464        let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
465        if !trimmed.isEmpty {
466            variables["filter"] = ["search": trimmed] as [String: any Sendable]
467        }
468
469        if useCache && cursor == nil {
470            if service == .hg {
471                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
472                let result = try await client.executeAndCache(
473                    service: service,
474                    query: Self.hgQuery,
475                    variables: hgVariables,
476                    responseType: HGRepositoriesResponse.self,
477                    cacheKey: cacheKey(for: service)
478                )
479                return Page(
480                    results: result.repositories?.results.map {
481                        RepositoryPayload(
482                            id: $0.id,
483                            rid: $0.rid,
484                            name: $0.name,
485                            description: $0.description,
486                            visibility: $0.visibility,
487                            updated: $0.updated,
488                            owner: $0.owner,
489                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
490                        )
491                    } ?? [],
492                    cursor: result.repositories?.cursor
493                )
494            }
495            let result = try await client.executeAndCache(
496                service: service,
497                query: Self.gitQuery,
498                variables: variables.isEmpty ? nil : variables,
499                responseType: RepositoriesResponse.self,
500                cacheKey: cacheKey(for: service)
501            )
502            return result.repositories ?? Self.emptyPage
503        } else {
504            if service == .hg {
505                let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
506                let result = try await client.execute(
507                    service: service,
508                    query: Self.hgQuery,
509                    variables: hgVariables,
510                    responseType: HGRepositoriesResponse.self
511                )
512                return Page(
513                    results: result.repositories?.results.map {
514                        RepositoryPayload(
515                            id: $0.id,
516                            rid: $0.rid,
517                            name: $0.name,
518                            description: $0.description,
519                            visibility: $0.visibility,
520                            updated: $0.updated,
521                            owner: $0.owner,
522                            head: $0.tip.map { Reference(name: $0.branch, target: nil) }
523                        )
524                    } ?? [],
525                    cursor: result.repositories?.cursor
526                )
527            }
528            let result = try await client.execute(
529                service: service,
530                query: Self.gitQuery,
531                variables: variables.isEmpty ? nil : variables,
532                responseType: RepositoriesResponse.self
533            )
534            return result.repositories ?? Self.emptyPage
535        }
536    }
537
538    private func loadFromCache() {
539        let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
540            guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
541            let decoder = JSONDecoder()
542            decoder.dateDecodingStrategy = .srhtFlexible
543            switch service {
544            case .git:
545                if let response = try? decoder.decode(
546                    GraphQLResponse<RepositoriesResponse>.self,
547                    from: data
548                ), let repos = response.data?.repositories {
549                    return repos.results.map { $0.repositorySummary(service: service) }
550                }
551            case .hg:
552                if let response = try? decoder.decode(
553                    GraphQLResponse<HGRepositoriesResponse>.self,
554                    from: data
555                ), let repos = response.data?.repositories {
556                    return repos.results.map { $0.repositorySummary(service: service) }
557                }
558            default:
559                break
560            }
561            return []
562        }
563        if !cachedRepositories.isEmpty {
564            let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
565            repositories = sortedRepositories
566            updateSearchIndex(with: sortedRepositories)
567            scheduleBuildStatusRefresh()
568        }
569    }
570
571    private func scheduleBuildStatusRefresh(force: Bool = false) {
572        // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
573        // passes force: true to bypass this check.
574        if !force, let last = lastBuildStatusRefresh,
575           Date().timeIntervalSince(last) < 120 {
576            return
577        }
578        let repositoriesSnapshot = repositories
579        buildStatusTask?.cancel()
580        buildStatusTask = Task { [weak self] in
581            guard let self else { return }
582            await self.loadLatestBuildStatuses(for: repositoriesSnapshot)
583        }
584    }
585
586    private func loadLatestBuildStatuses(for repositories: [RepositorySummary]) async {
587        let targetKeys = Set(repositories.map(Self.buildStatusCacheKey(for:)))
588        guard !targetKeys.isEmpty else {
589            await MainActor.run {
590                latestBuildStatuses = [:]
591            }
592            return
593        }
594
595        var resolvedStatuses: [String: (Date, RepositoryBuildStatus)] = [:]
596        var cursor: String?
597        var shouldUseCache = true
598
599        do {
600            while !Task.isCancelled {
601                let page = try await fetchBuildStatusPage(cursor: cursor, useCache: shouldUseCache)
602                shouldUseCache = false
603
604                for job in page.results {
605                    let jobStatus = Self.repositoryBuildStatus(for: job.status)
606                    guard let manifest = job.manifest else { continue }
607
608                    for key in Self.buildStatusKeys(in: manifest) where targetKeys.contains(key) {
609                        let existing = resolvedStatuses[key]
610                        if existing == nil || existing!.0 < job.created {
611                            resolvedStatuses[key] = (job.created, jobStatus)
612                        }
613                    }
614                }
615
616                if resolvedStatuses.count == targetKeys.count || page.cursor == nil {
617                    break
618                }
619                cursor = page.cursor
620            }
621
622            let finalStatuses = targetKeys.reduce(into: [String: RepositoryBuildStatus]()) { result, key in
623                result[key] = resolvedStatuses[key]?.1 ?? RepositoryBuildStatus.none
624            }
625
626            await MainActor.run {
627                guard repositories == self.repositories else { return }
628                latestBuildStatuses = finalStatuses
629                lastBuildStatusRefresh = Date()
630            }
631        } catch {
632            // Build status is auxiliary data for the list. Leave the default gray state on failure.
633        }
634    }
635
636    private func fetchBuildStatusPage(cursor: String?, useCache: Bool) async throws -> BuildJobsPage {
637        var variables: [String: any Sendable] = [:]
638        if let cursor {
639            variables["cursor"] = cursor
640        }
641
642        if useCache && cursor == nil {
643            let result = try await client.executeAndCache(
644                service: .builds,
645                query: Self.buildsQuery,
646                variables: variables.isEmpty ? nil : variables,
647                responseType: BuildJobsResponse.self,
648                cacheKey: Self.buildsCacheKey
649            )
650            return result.jobs
651        }
652
653        let result = try await client.execute(
654            service: .builds,
655            query: Self.buildsQuery,
656            variables: variables.isEmpty ? nil : variables,
657            responseType: BuildJobsResponse.self
658        )
659        return result.jobs
660    }
661
662    private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
663        var allRepositories: [RepositorySummary] = []
664        var currentCursor: String? = nil
665
666        while true {
667            let page = try await fetchPage(
668                service: service,
669                cursor: currentCursor,
670                search: nil,
671                useCache: useCache && currentCursor == nil
672            )
673            allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
674            guard let nextCursor = page.cursor else { break }
675            currentCursor = nextCursor
676        }
677
678        return allRepositories
679    }
680
681    private func cacheKey(for service: SRHTService) -> String {
682        switch service {
683        case .git:
684            Self.gitCacheKey
685        case .hg:
686            Self.hgCacheKey
687        default:
688            "\(service.rawValue).repositories"
689        }
690    }
691
692    private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
693        if lhs.updated == rhs.updated {
694            if lhs.service == rhs.service {
695                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
696            }
697            return lhs.service.rawValue < rhs.service.rawValue
698        }
699        return lhs.updated > rhs.updated
700    }
701
702    private func updateSearchIndex(with repositories: [RepositorySummary]) {
703        searchIndex = repositories.sorted(by: repositorySortOrder)
704        hasLoadedSearchIndex = !searchIndex.isEmpty
705    }
706
707    private func insertIntoSearchIndex(_ repository: RepositorySummary) {
708        let updatedRepositories = (repositoriesForSearchIndex + [repository])
709            .uniqued(on: \.id)
710            .sorted(by: repositorySortOrder)
711        updateSearchIndex(with: updatedRepositories)
712    }
713
714    static func shouldRefreshSearchIndex(for query: String) -> Bool {
715        query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
716    }
717
718    static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
719        let lowercasedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
720        guard !lowercasedQuery.isEmpty else { return repositories }
721
722        return repositories.filter { repo in
723            repo.name.lowercased().contains(lowercasedQuery) ||
724            repo.owner.canonicalName.lowercased().contains(lowercasedQuery) ||
725            repo.defaultBranchName?.lowercased().contains(lowercasedQuery) ?? false ||
726            repo.description?.lowercased().contains(lowercasedQuery) ?? false
727        }
728    }
729
730    nonisolated static func buildStatusCacheKey(for repository: RepositorySummary) -> String {
731        buildStatusCacheKey(
732            service: repository.service,
733            ownerCanonicalName: repository.owner.canonicalName,
734            repositoryName: repository.name
735        )
736    }
737
738    nonisolated static func buildStatusCacheKey(
739        service: SRHTService,
740        ownerCanonicalName: String,
741        repositoryName: String
742    ) -> String {
743        "\(service.rawValue)|\(ownerCanonicalName.lowercased())|\(repositoryName.lowercased())"
744    }
745
746    nonisolated static func repositoryBuildStatus(for jobStatus: JobStatus) -> RepositoryBuildStatus {
747        switch jobStatus {
748        case .success:
749            .success
750        case .pending, .queued, .running:
751            .running
752        case .failed, .cancelled, .timeout:
753            .failed
754        }
755    }
756
757    nonisolated static func buildStatusKeys(in manifest: String) -> Set<String> {
758        let pattern = #"(?:https://|ssh://(?:git|hg)@|(?:git|hg)@)(git|hg)\.sr\.ht[:/]([~][^/\s]+)/([^\s"'#]+)"#
759        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
760            return []
761        }
762
763        let nsRange = NSRange(manifest.startIndex..<manifest.endIndex, in: manifest)
764        return regex.matches(in: manifest, options: [], range: nsRange).reduce(into: Set<String>()) { result, match in
765            guard
766                let serviceRange = Range(match.range(at: 1), in: manifest),
767                let ownerRange = Range(match.range(at: 2), in: manifest),
768                let nameRange = Range(match.range(at: 3), in: manifest)
769            else {
770                return
771            }
772
773            let service: SRHTService = manifest[serviceRange].lowercased() == "hg" ? .hg : .git
774            let owner = String(manifest[ownerRange]).lowercased()
775            var name = String(manifest[nameRange]).lowercased()
776
777            if let suffixRange = name.range(of: ".git", options: [.backwards, .anchored]) {
778                name.removeSubrange(suffixRange)
779            }
780            name = name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
781            if !name.isEmpty {
782                result.insert(buildStatusCacheKey(
783                    service: service,
784                    ownerCanonicalName: owner,
785                    repositoryName: name
786                ))
787            }
788        }
789    }
790}
791
792private extension Array {
793    func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
794        var seenIDs: Set<ID> = []
795        return filter { element in
796            seenIDs.insert(element[keyPath: keyPath]).inserted
797        }
798    }
799}