krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v3.10.0: Hutch/Networking/ProjectService.swift · raw
1import Foundation
2
3private struct ProjectPageResponse: Decodable, Sendable {
4 let me: ProjectPageUser
5}
6
7private struct ProjectPageUser: Decodable, Sendable {
8 let projects: ProjectPage
9}
10
11private struct ProjectPage: Decodable, Sendable {
12 let results: [ProjectSummaryPayload]
13 let cursor: String?
14
15 private enum CodingKeys: String, CodingKey {
16 case results
17 case cursor
18 }
19
20 init(from decoder: any Decoder) throws {
21 let container = try decoder.container(keyedBy: CodingKeys.self)
22 results = try container.decodeIfPresent([ProjectSummaryPayload].self, forKey: .results) ?? []
23 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
24 }
25}
26
27private struct ProjectSummaryPayload: Decodable, Sendable {
28 let rid: String
29 let name: String
30 let description: String?
31 let website: String?
32 let visibility: Visibility
33 let tags: [String]
34 let updated: Date
35
36 private enum CodingKeys: String, CodingKey {
37 case rid
38 case name
39 case description
40 case website
41 case visibility
42 case tags
43 case updated
44 }
45
46 init(from decoder: any Decoder) throws {
47 let container = try decoder.container(keyedBy: CodingKeys.self)
48 rid = try container.decode(String.self, forKey: .rid)
49 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
50 description = try container.decodeIfPresent(String.self, forKey: .description)
51 website = try container.decodeIfPresent(String.self, forKey: .website)
52 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
53 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
54 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
55 }
56}
57
58private struct ProjectDetailResponse: Decodable, Sendable {
59 let project: ProjectDetailPayload?
60}
61
62private struct ProjectDetailPayload: Decodable, Sendable {
63 let rid: String
64 let name: String
65 let description: String?
66 let website: String?
67 let visibility: Visibility
68 let tags: [String]
69 let updated: Date
70 let mailingLists: ProjectMailingListPage
71 let sources: ProjectSourcePage
72 let trackers: ProjectTrackerPage
73
74 private enum CodingKeys: String, CodingKey {
75 case rid
76 case name
77 case description
78 case website
79 case visibility
80 case tags
81 case updated
82 case mailingLists
83 case sources
84 case trackers
85 }
86
87 init(from decoder: any Decoder) throws {
88 let container = try decoder.container(keyedBy: CodingKeys.self)
89 rid = try container.decode(String.self, forKey: .rid)
90 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
91 description = try container.decodeIfPresent(String.self, forKey: .description)
92 website = try container.decodeIfPresent(String.self, forKey: .website)
93 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
94 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
95 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
96 mailingLists = try container.decodeIfPresent(ProjectMailingListPage.self, forKey: .mailingLists) ?? .empty
97 sources = try container.decodeIfPresent(ProjectSourcePage.self, forKey: .sources) ?? .empty
98 trackers = try container.decodeIfPresent(ProjectTrackerPage.self, forKey: .trackers) ?? .empty
99 }
100}
101
102private struct ProjectMailingListPage: Decodable, Sendable {
103 let results: [ProjectMailingListPayload]
104 let cursor: String?
105
106 static let empty = ProjectMailingListPage(results: [], cursor: nil)
107}
108
109private struct ProjectMailingListPayload: Decodable, Sendable {
110 let rid: String
111 let name: String
112 let description: String?
113 let visibility: Visibility
114 let owner: Entity
115
116 private enum CodingKeys: String, CodingKey {
117 case rid
118 case name
119 case description
120 case visibility
121 case owner
122 }
123
124 init(from decoder: any Decoder) throws {
125 let container = try decoder.container(keyedBy: CodingKeys.self)
126 rid = try container.decode(String.self, forKey: .rid)
127 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
128 description = try container.decodeIfPresent(String.self, forKey: .description)
129 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
130 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
131 }
132}
133
134private struct ProjectSourcePage: Decodable, Sendable {
135 let results: [ProjectSourcePayload]
136 let cursor: String?
137
138 static let empty = ProjectSourcePage(results: [], cursor: nil)
139}
140
141private struct ProjectSourcePayload: Decodable, Sendable {
142 let rid: String
143 let name: String
144 let description: String?
145 let visibility: Visibility
146 let owner: Entity
147 let repoType: Project.SourceRepo.RepoType
148
149 private enum CodingKeys: String, CodingKey {
150 case rid
151 case name
152 case description
153 case visibility
154 case owner
155 case repoType
156 }
157
158 init(from decoder: any Decoder) throws {
159 let container = try decoder.container(keyedBy: CodingKeys.self)
160 rid = try container.decode(String.self, forKey: .rid)
161 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
162 description = try container.decodeIfPresent(String.self, forKey: .description)
163 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
164 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
165 repoType = try container.decodeIfPresent(Project.SourceRepo.RepoType.self, forKey: .repoType) ?? .git
166 }
167}
168
169private struct ProjectTrackerPage: Decodable, Sendable {
170 let results: [ProjectTrackerPayload]
171 let cursor: String?
172
173 static let empty = ProjectTrackerPage(results: [], cursor: nil)
174}
175
176private struct ProjectTrackerPayload: Decodable, Sendable {
177 let rid: String
178 let name: String
179 let description: String?
180 let visibility: Visibility
181 let owner: Entity
182
183 private enum CodingKeys: String, CodingKey {
184 case rid
185 case name
186 case description
187 case visibility
188 case owner
189 }
190
191 init(from decoder: any Decoder) throws {
192 let container = try decoder.container(keyedBy: CodingKeys.self)
193 rid = try container.decode(String.self, forKey: .rid)
194 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
195 description = try container.decodeIfPresent(String.self, forKey: .description)
196 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
197 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
198 }
199}
200
201/// A public project surfaced by discovery, carrying its owner for display.
202struct DiscoveredProject: Identifiable, Hashable, Sendable {
203 let project: Project
204 let ownerCanonicalName: String
205
206 var id: String { project.id }
207}
208
209struct DiscoveredProjectsPage: Sendable {
210 let projects: [DiscoveredProject]
211 let cursor: String?
212}
213
214private struct PublicProjectsResponse: Decodable, Sendable {
215 let projects: PublicProjectPage
216}
217
218private struct PublicProjectPage: Decodable, Sendable {
219 let results: [PublicProjectPayload]
220 let cursor: String?
221
222 init(from decoder: any Decoder) throws {
223 enum CodingKeys: String, CodingKey { case results, cursor }
224 let container = try decoder.container(keyedBy: CodingKeys.self)
225 results = try container.decodeIfPresent([PublicProjectPayload].self, forKey: .results) ?? []
226 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
227 }
228}
229
230private struct PublicProjectPayload: Decodable, Sendable {
231 let rid: String
232 let name: String
233 let description: String?
234 let website: String?
235 let visibility: Visibility
236 let tags: [String]
237 let updated: Date
238 let owner: Entity
239
240 init(from decoder: any Decoder) throws {
241 enum CodingKeys: String, CodingKey {
242 case rid, name, description, website, visibility, tags, updated, owner
243 }
244 let container = try decoder.container(keyedBy: CodingKeys.self)
245 rid = try container.decode(String.self, forKey: .rid)
246 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
247 description = try container.decodeIfPresent(String.self, forKey: .description)
248 website = try container.decodeIfPresent(String.self, forKey: .website)
249 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
250 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
251 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
252 owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown")
253 }
254}
255
256/// A resource the current user can link to a project (#15 add flow).
257struct LinkableResource: Identifiable, Hashable, Sendable {
258 enum Kind: Sendable { case source, tracker, mailingList }
259
260 let rid: String
261 let name: String
262 let ownerCanonicalName: String
263 let kind: Kind
264
265 var id: String { rid }
266 var displayName: String { "\(ownerCanonicalName)/\(name)" }
267}
268
269private struct CandidatePayload: Decodable, Sendable {
270 let rid: String
271 let name: String
272 let owner: Entity?
273}
274
275private struct CandidatePage: Decodable, Sendable {
276 let results: [CandidatePayload]
277 let cursor: String?
278
279 init(from decoder: any Decoder) throws {
280 enum CodingKeys: String, CodingKey { case results, cursor }
281 let container = try decoder.container(keyedBy: CodingKeys.self)
282 results = try container.decodeIfPresent([CandidatePayload].self, forKey: .results) ?? []
283 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
284 }
285}
286
287private struct RepoCandidatesResponse: Decodable, Sendable {
288 let repositories: CandidatePage
289}
290
291private struct TrackerCandidatesResponse: Decodable, Sendable {
292 let trackers: CandidatePage
293}
294
295private struct ListSubscriptionPayload: Decodable, Sendable {
296 let list: CandidatePayload?
297}
298
299private struct ListSubscriptionPage: Decodable, Sendable {
300 let results: [ListSubscriptionPayload]
301 let cursor: String?
302
303 init(from decoder: any Decoder) throws {
304 enum CodingKeys: String, CodingKey { case results, cursor }
305 let container = try decoder.container(keyedBy: CodingKeys.self)
306 results = try container.decodeIfPresent([ListSubscriptionPayload].self, forKey: .results) ?? []
307 cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
308 }
309}
310
311private struct ListCandidatesResponse: Decodable, Sendable {
312 let subscriptions: ListSubscriptionPage
313}
314
315/// Link/unlink mutations only need success; the returned resource is ignored.
316private struct LinkMutationResponse: Decodable, Sendable {}
317
318private struct CreateProjectResponse: Decodable, Sendable {
319 let createProject: MutatedProjectPayload?
320}
321
322private struct UpdateProjectResponse: Decodable, Sendable {
323 let updateProject: MutatedProjectPayload?
324}
325
326private struct MutatedProjectPayload: Decodable, Sendable {
327 let rid: String
328 let name: String
329 let description: String?
330 let website: String?
331 let visibility: Visibility
332 let tags: [String]
333 let updated: Date
334
335 init(from decoder: any Decoder) throws {
336 enum CodingKeys: String, CodingKey {
337 case rid, name, description, website, visibility, tags, updated
338 }
339 let container = try decoder.container(keyedBy: CodingKeys.self)
340 rid = try container.decode(String.self, forKey: .rid)
341 name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
342 description = try container.decodeIfPresent(String.self, forKey: .description)
343 website = try container.decodeIfPresent(String.self, forKey: .website)
344 visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility
345 tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? []
346 updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast
347 }
348
349 var project: Project {
350 Project(
351 metadata: .init(
352 id: rid,
353 name: name,
354 description: description,
355 website: website,
356 visibility: visibility,
357 tags: tags,
358 updated: updated
359 ),
360 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
361 )
362 }
363}
364
365struct ProjectService: Sendable {
366 private let client: SRHTClient
367
368 private static let projectsQuery = """
369 query meProjects($cursor: Cursor) {
370 me {
371 projects(cursor: $cursor) {
372 results {
373 rid
374 name
375 description
376 website
377 visibility
378 tags
379 updated
380 }
381 cursor
382 }
383 }
384 }
385 """
386
387 private static let projectDetailQuery = """
388 query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
389 project(rid: $rid) {
390 rid
391 name
392 description
393 website
394 visibility
395 tags
396 updated
397 mailingLists(cursor: $mailingListsCursor) {
398 results {
399 rid
400 name
401 description
402 visibility
403 owner { canonicalName }
404 }
405 cursor
406 }
407 sources(cursor: $sourcesCursor) {
408 results {
409 rid
410 name
411 description
412 visibility
413 owner { canonicalName }
414 repoType
415 }
416 cursor
417 }
418 trackers(cursor: $trackersCursor) {
419 results {
420 rid
421 name
422 description
423 visibility
424 owner { canonicalName }
425 }
426 cursor
427 }
428 }
429 }
430 """
431
432 private static let publicProjectsQuery = """
433 query publicProjects($cursor: Cursor) {
434 projects(cursor: $cursor) {
435 results {
436 rid
437 name
438 description
439 website
440 visibility
441 tags
442 updated
443 owner { canonicalName }
444 }
445 cursor
446 }
447 }
448 """
449
450 private static let createProjectMutation = """
451 mutation createProject($name: String!, $visibility: Visibility!, $description: String, $tags: [String!]) {
452 createProject(name: $name, visibility: $visibility, description: $description, tags: $tags) {
453 rid
454 name
455 description
456 website
457 visibility
458 tags
459 updated
460 }
461 }
462 """
463
464 private static let updateProjectMutation = """
465 mutation updateProject($rid: ID!, $input: ProjectInput!) {
466 updateProject(rid: $rid, input: $input) {
467 rid
468 name
469 description
470 website
471 visibility
472 tags
473 updated
474 }
475 }
476 """
477
478 private static func linkMutation(field: String, resourceParam: String) -> String {
479 """
480 mutation link($projectID: ID!, $resourceID: ID!) {
481 \(field)(projectID: $projectID, \(resourceParam): $resourceID) { rid }
482 }
483 """
484 }
485
486 private static let repositoriesCandidatesQuery = """
487 query repositories($cursor: Cursor) {
488 repositories(cursor: $cursor) {
489 results { rid name owner { canonicalName } }
490 cursor
491 }
492 }
493 """
494
495 private static let trackersCandidatesQuery = """
496 query trackers($cursor: Cursor) {
497 trackers(cursor: $cursor) {
498 results { rid name owner { canonicalName } }
499 cursor
500 }
501 }
502 """
503
504 private static let listCandidatesQuery = """
505 query subscriptions($cursor: Cursor) {
506 subscriptions(cursor: $cursor) {
507 results {
508 ... on MailingListSubscription {
509 list { rid name owner { canonicalName } }
510 }
511 }
512 cursor
513 }
514 }
515 """
516
517 init(client: SRHTClient) {
518 self.client = client
519 }
520
521 func fetchProjects(forceRefresh: Bool = false) async throws -> [Project] {
522 try await fetchProjectSummaries(forceRefresh: forceRefresh).map(Self.makeSummaryProject)
523 }
524
525 func fetchProjectDetail(rid: String) async throws -> Project {
526 try await fetchProjectDetailPayload(rid: rid)
527 }
528
529 // MARK: - Discovery (#12)
530
531 /// Lists public projects across all users. Not cached — discovery is
532 /// browsed live and paginated by the caller.
533 func fetchPublicProjects(cursor: String? = nil) async throws -> DiscoveredProjectsPage {
534 var variables: [String: any Sendable] = [:]
535 if let cursor {
536 variables["cursor"] = cursor
537 }
538
539 let response = try await client.execute(
540 service: .hub,
541 query: Self.publicProjectsQuery,
542 variables: variables.isEmpty ? nil : variables,
543 responseType: PublicProjectsResponse.self
544 )
545
546 let projects = response.projects.results.map { payload in
547 DiscoveredProject(
548 project: Project(
549 metadata: .init(
550 id: payload.rid,
551 name: payload.name,
552 description: payload.description,
553 website: payload.website,
554 visibility: payload.visibility,
555 tags: payload.tags,
556 updated: payload.updated
557 ),
558 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
559 ),
560 ownerCanonicalName: payload.owner.canonicalName
561 )
562 }
563 return DiscoveredProjectsPage(projects: projects, cursor: response.projects.cursor)
564 }
565
566 // MARK: - Mutations (#13, #14, #15)
567
568 func createProject(
569 name: String,
570 visibility: Visibility,
571 description: String?,
572 tags: [String]
573 ) async throws -> Project {
574 var variables: [String: any Sendable] = ["name": name, "visibility": visibility.rawValue]
575 if let description, !description.isEmpty {
576 variables["description"] = description
577 }
578 if !tags.isEmpty {
579 variables["tags"] = tags
580 }
581
582 let response = try await client.execute(
583 service: .hub,
584 query: Self.createProjectMutation,
585 variables: variables,
586 responseType: CreateProjectResponse.self
587 )
588 await invalidateProjectCaches()
589
590 guard let payload = response.createProject else {
591 throw SRHTError.decodingError(
592 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing createProject payload"))
593 )
594 }
595 return payload.project
596 }
597
598 /// Updates a project. Only non-nil fields are sent; `description`/`website`
599 /// pass an empty string to clear the field.
600 func updateProject(
601 rid: String,
602 name: String? = nil,
603 description: String? = nil,
604 website: String? = nil,
605 visibility: Visibility? = nil,
606 tags: [String]? = nil
607 ) async throws -> Project {
608 var input: [String: any Sendable] = [:]
609 if let name {
610 input["name"] = name
611 }
612 if let description {
613 input["description"] = description
614 }
615 if let website {
616 input["website"] = website
617 }
618 if let visibility {
619 input["visibility"] = visibility.rawValue
620 }
621 if let tags {
622 input["tags"] = tags
623 }
624
625 let response = try await client.execute(
626 service: .hub,
627 query: Self.updateProjectMutation,
628 variables: ["rid": rid, "input": input],
629 responseType: UpdateProjectResponse.self
630 )
631 await invalidateProjectCaches()
632
633 guard let payload = response.updateProject else {
634 throw SRHTError.decodingError(
635 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing updateProject payload"))
636 )
637 }
638 return payload.project
639 }
640
641 func linkSource(projectID: String, sourceRepoID: String) async throws {
642 try await runLink(field: "linkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
643 }
644
645 func unlinkSource(projectID: String, sourceRepoID: String) async throws {
646 try await runLink(field: "unlinkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID)
647 }
648
649 func linkTracker(projectID: String, trackerID: String) async throws {
650 try await runLink(field: "linkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
651 }
652
653 func unlinkTracker(projectID: String, trackerID: String) async throws {
654 try await runLink(field: "unlinkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID)
655 }
656
657 func linkMailingList(projectID: String, listID: String) async throws {
658 try await runLink(field: "linkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
659 }
660
661 func unlinkMailingList(projectID: String, listID: String) async throws {
662 try await runLink(field: "unlinkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID)
663 }
664
665 private func runLink(field: String, resourceParam: String, projectID: String, resourceID: String) async throws {
666 _ = try await client.execute(
667 service: .hub,
668 query: Self.linkMutation(field: field, resourceParam: resourceParam),
669 variables: ["projectID": projectID, "resourceID": resourceID],
670 responseType: LinkMutationResponse.self
671 )
672 await invalidateProjectCaches()
673 }
674
675 private func invalidateProjectCaches() async {
676 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.hub.rawValue))
677 }
678
679 // MARK: - Linkable resource candidates (#15 add flow)
680
681 /// Repositories the user can link (git and hg), sorted by display name.
682 func fetchLinkableSources() async throws -> [LinkableResource] {
683 async let git = fetchRepoCandidates(service: .git)
684 async let hg = fetchRepoCandidates(service: .hg)
685 return dedupeSorted(try await git + (try await hg))
686 }
687
688 func fetchLinkableTrackers() async throws -> [LinkableResource] {
689 var results: [LinkableResource] = []
690 var cursor: String?
691 repeat {
692 let response = try await client.execute(
693 service: .todo,
694 query: Self.trackersCandidatesQuery,
695 variables: cursor.map { ["cursor": $0] },
696 responseType: TrackerCandidatesResponse.self
697 )
698 results.append(contentsOf: response.trackers.results.map {
699 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .tracker)
700 })
701 cursor = response.trackers.cursor
702 } while cursor != nil
703 return dedupeSorted(results)
704 }
705
706 func fetchLinkableMailingLists() async throws -> [LinkableResource] {
707 var results: [LinkableResource] = []
708 var cursor: String?
709 repeat {
710 let response = try await client.execute(
711 service: .lists,
712 query: Self.listCandidatesQuery,
713 variables: cursor.map { ["cursor": $0] },
714 responseType: ListCandidatesResponse.self
715 )
716 results.append(contentsOf: response.subscriptions.results.compactMap(\.list).map {
717 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .mailingList)
718 })
719 cursor = response.subscriptions.cursor
720 } while cursor != nil
721 return dedupeSorted(results)
722 }
723
724 private func fetchRepoCandidates(service: SRHTService) async throws -> [LinkableResource] {
725 var results: [LinkableResource] = []
726 var cursor: String?
727 repeat {
728 let response = try await client.execute(
729 service: service,
730 query: Self.repositoriesCandidatesQuery,
731 variables: cursor.map { ["cursor": $0] },
732 responseType: RepoCandidatesResponse.self
733 )
734 results.append(contentsOf: response.repositories.results.map {
735 LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .source)
736 })
737 cursor = response.repositories.cursor
738 } while cursor != nil
739 return results
740 }
741
742 private func dedupeSorted(_ items: [LinkableResource]) -> [LinkableResource] {
743 var seen = Set<String>()
744 return items
745 .filter { seen.insert($0.rid).inserted }
746 .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
747 }
748
749 private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] {
750 var results: [ProjectSummaryPayload] = []
751 var cursor: String?
752
753 while true {
754 var variables: [String: any Sendable] = [:]
755 if let cursor {
756 variables["cursor"] = cursor
757 }
758
759 let cached = try await client.executeCached(
760 service: .hub,
761 query: Self.projectsQuery,
762 variables: variables.isEmpty ? nil : variables,
763 responseType: ProjectPageResponse.self,
764 cacheKey: APICacheKeys.projects(cursor: cursor),
765 resourceType: .userProfile,
766 ttl: APICacheTTLs.projectList,
767 policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh
768 )
769 let response = cached.value
770
771 results.append(contentsOf: response.me.projects.results)
772 guard let nextCursor = response.me.projects.cursor else {
773 break
774 }
775 cursor = nextCursor
776 }
777
778 return results.sorted { $0.updated > $1.updated }
779 }
780
781 private func fetchProjectDetailPayload(rid: String) async throws -> Project {
782 var mailingLists: [Project.MailingList] = []
783 var sources: [Project.SourceRepo] = []
784 var trackers: [Project.Tracker] = []
785 var mailingListsCursor: String?
786 var sourcesCursor: String?
787 var trackersCursor: String?
788
789 while true {
790 var variables: [String: any Sendable] = ["rid": rid]
791 if let mailingListsCursor {
792 variables["mailingListsCursor"] = mailingListsCursor
793 }
794 if let sourcesCursor {
795 variables["sourcesCursor"] = sourcesCursor
796 }
797 if let trackersCursor {
798 variables["trackersCursor"] = trackersCursor
799 }
800
801 let cached = try await client.executeCached(
802 service: .hub,
803 query: Self.projectDetailQuery,
804 variables: variables,
805 responseType: ProjectDetailResponse.self,
806 cacheKey: APICacheKeys.projectDetail(
807 rid: rid,
808 mailingListsCursor: mailingListsCursor,
809 sourcesCursor: sourcesCursor,
810 trackersCursor: trackersCursor
811 ),
812 resourceType: .userProfile,
813 ttl: APICacheTTLs.projectDetail,
814 policy: .cacheFirstThenRefresh
815 )
816 let response = cached.value
817
818 guard let project = response.project else {
819 throw SRHTError.decodingError(
820 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
821 )
822 }
823
824 mailingLists.append(contentsOf: project.mailingLists.results.map {
825 Project.MailingList(
826 id: $0.rid,
827 name: $0.name,
828 description: $0.description,
829 visibility: $0.visibility,
830 owner: $0.owner
831 )
832 })
833 sources.append(contentsOf: project.sources.results.map {
834 Project.SourceRepo(
835 id: $0.rid,
836 name: $0.name,
837 description: $0.description,
838 visibility: $0.visibility,
839 owner: $0.owner,
840 repoType: $0.repoType
841 )
842 })
843 trackers.append(contentsOf: project.trackers.results.map {
844 Project.Tracker(
845 id: $0.rid,
846 name: $0.name,
847 description: $0.description,
848 visibility: $0.visibility,
849 owner: $0.owner
850 )
851 })
852
853 mailingListsCursor = project.mailingLists.cursor
854 sourcesCursor = project.sources.cursor
855 trackersCursor = project.trackers.cursor
856
857 if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
858 return Project(
859 metadata: .init(
860 id: project.rid,
861 name: project.name,
862 description: project.description,
863 website: project.website,
864 visibility: project.visibility,
865 tags: project.tags,
866 updated: project.updated
867 ),
868 resources: .init(
869 mailingLists: deduplicate(mailingLists),
870 sources: deduplicate(sources),
871 trackers: deduplicate(trackers),
872 isFullyLoaded: true
873 )
874 )
875 }
876 }
877 }
878
879 private static func makeSummaryProject(from summary: ProjectSummaryPayload) -> Project {
880 Project(
881 metadata: .init(
882 id: summary.rid,
883 name: summary.name,
884 description: summary.description,
885 website: summary.website,
886 visibility: summary.visibility,
887 tags: summary.tags,
888 updated: summary.updated
889 ),
890 resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false)
891 )
892 }
893
894 private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
895 var seen = Set<T.ID>()
896 return items.filter { item in
897 seen.insert(item.id).inserted
898 }
899 }
900}