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