krz/hutch

an ios client for sourcehut

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

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