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}