krz/hutch

an ios client for sourcehut

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

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