krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

main: 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}