krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.1.7: 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}