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