krz/hutch

an ios client for sourcehut

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

v2.5.2: 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
 16private struct ProjectSummaryPayload: Decodable, Sendable {
 17    let rid: String
 18    let name: String
 19    let description: String?
 20    let website: String?
 21    let visibility: Visibility
 22    let tags: [String]
 23}
 24
 25private struct ProjectDetailResponse: Decodable, Sendable {
 26    let project: ProjectDetailPayload?
 27}
 28
 29private struct ProjectDetailPayload: Decodable, Sendable {
 30    let rid: String
 31    let name: String
 32    let description: String?
 33    let website: String?
 34    let visibility: Visibility
 35    let tags: [String]
 36    let mailingLists: ProjectMailingListPage
 37    let sources: ProjectSourcePage
 38    let trackers: ProjectTrackerPage
 39}
 40
 41private struct ProjectMailingListPage: Decodable, Sendable {
 42    let results: [ProjectMailingListPayload]
 43    let cursor: String?
 44}
 45
 46private struct ProjectMailingListPayload: Decodable, Sendable {
 47    let rid: String
 48    let name: String
 49    let description: String?
 50    let visibility: Visibility
 51    let owner: Entity
 52}
 53
 54private struct ProjectSourcePage: Decodable, Sendable {
 55    let results: [ProjectSourcePayload]
 56    let cursor: String?
 57}
 58
 59private struct ProjectSourcePayload: Decodable, Sendable {
 60    let rid: String
 61    let name: String
 62    let description: String?
 63    let visibility: Visibility
 64    let owner: Entity
 65    let repoType: Project.SourceRepo.RepoType
 66}
 67
 68private struct ProjectTrackerPage: Decodable, Sendable {
 69    let results: [ProjectTrackerPayload]
 70    let cursor: String?
 71}
 72
 73private struct ProjectTrackerPayload: Decodable, Sendable {
 74    let rid: String
 75    let name: String
 76    let description: String?
 77    let visibility: Visibility
 78    let owner: Entity
 79}
 80
 81struct ProjectService: Sendable {
 82    private let client: SRHTClient
 83
 84    private static let projectsQuery = """
 85    query meProjects($cursor: Cursor) {
 86        me {
 87            projects(cursor: $cursor) {
 88                results {
 89                    rid
 90                    name
 91                    description
 92                    website
 93                    visibility
 94                    tags
 95                }
 96                cursor
 97            }
 98        }
 99    }
100    """
101
102    private static let projectDetailQuery = """
103    query projectDetail($rid: ID!, $mailingListsCursor: Cursor, $sourcesCursor: Cursor, $trackersCursor: Cursor) {
104        project(rid: $rid) {
105            rid
106            name
107            description
108            website
109            visibility
110            tags
111            mailingLists(cursor: $mailingListsCursor) {
112                results {
113                    rid
114                    name
115                    description
116                    visibility
117                    owner { canonicalName }
118                }
119                cursor
120            }
121            sources(cursor: $sourcesCursor) {
122                results {
123                    rid
124                    name
125                    description
126                    visibility
127                    owner { canonicalName }
128                    repoType
129                }
130                cursor
131            }
132            trackers(cursor: $trackersCursor) {
133                results {
134                    rid
135                    name
136                    description
137                    visibility
138                    owner { canonicalName }
139                }
140                cursor
141            }
142        }
143    }
144    """
145
146    init(client: SRHTClient) {
147        self.client = client
148    }
149
150    func fetchProjects() async throws -> [Project] {
151        let summaries = try await fetchProjectSummaries()
152        guard !summaries.isEmpty else { return [] }
153
154        return try await withThrowingTaskGroup(of: Project.self) { group in
155            for summary in summaries {
156                group.addTask {
157                    try await self.fetchProjectDetail(summary: summary)
158                }
159            }
160
161            var projects: [Project] = []
162            for try await project in group {
163                projects.append(project)
164            }
165            return projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
166        }
167    }
168
169    private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] {
170        var results: [ProjectSummaryPayload] = []
171        var cursor: String?
172
173        while true {
174            var variables: [String: any Sendable] = [:]
175            if let cursor {
176                variables["cursor"] = cursor
177            }
178
179            let response = try await client.execute(
180                service: .hub,
181                query: Self.projectsQuery,
182                variables: variables.isEmpty ? nil : variables,
183                responseType: ProjectPageResponse.self
184            )
185
186            results.append(contentsOf: response.me.projects.results)
187            guard let nextCursor = response.me.projects.cursor else {
188                break
189            }
190            cursor = nextCursor
191        }
192
193        return results
194    }
195
196    private func fetchProjectDetail(summary: ProjectSummaryPayload) async throws -> Project {
197        var mailingLists: [Project.MailingList] = []
198        var sources: [Project.SourceRepo] = []
199        var trackers: [Project.Tracker] = []
200        var mailingListsCursor: String?
201        var sourcesCursor: String?
202        var trackersCursor: String?
203
204        while true {
205            var variables: [String: any Sendable] = ["rid": summary.rid]
206            if let mailingListsCursor {
207                variables["mailingListsCursor"] = mailingListsCursor
208            }
209            if let sourcesCursor {
210                variables["sourcesCursor"] = sourcesCursor
211            }
212            if let trackersCursor {
213                variables["trackersCursor"] = trackersCursor
214            }
215
216            let response = try await client.execute(
217                service: .hub,
218                query: Self.projectDetailQuery,
219                variables: variables,
220                responseType: ProjectDetailResponse.self
221            )
222
223            guard let project = response.project else {
224                throw SRHTError.decodingError(
225                    DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing project payload"))
226                )
227            }
228
229            mailingLists.append(contentsOf: project.mailingLists.results.map {
230                Project.MailingList(
231                    id: $0.rid,
232                    name: $0.name,
233                    description: $0.description,
234                    visibility: $0.visibility,
235                    owner: $0.owner
236                )
237            })
238            sources.append(contentsOf: project.sources.results.map {
239                Project.SourceRepo(
240                    id: $0.rid,
241                    name: $0.name,
242                    description: $0.description,
243                    visibility: $0.visibility,
244                    owner: $0.owner,
245                    repoType: $0.repoType
246                )
247            })
248            trackers.append(contentsOf: project.trackers.results.map {
249                Project.Tracker(
250                    id: $0.rid,
251                    name: $0.name,
252                    description: $0.description,
253                    visibility: $0.visibility,
254                    owner: $0.owner
255                )
256            })
257
258            mailingListsCursor = project.mailingLists.cursor
259            sourcesCursor = project.sources.cursor
260            trackersCursor = project.trackers.cursor
261
262            if mailingListsCursor == nil, sourcesCursor == nil, trackersCursor == nil {
263                return Project(
264                    id: project.rid,
265                    name: project.name,
266                    description: project.description,
267                    website: project.website,
268                    visibility: project.visibility,
269                    tags: project.tags,
270                    mailingLists: deduplicate(mailingLists),
271                    sources: deduplicate(sources),
272                    trackers: deduplicate(trackers)
273                )
274            }
275        }
276    }
277
278    private func deduplicate<T: Identifiable & Hashable>(_ items: [T]) -> [T] where T.ID: Hashable {
279        var seen = Set<T.ID>()
280        return items.filter { item in
281            seen.insert(item.id).inserted
282        }
283    }
284}