krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
v2.8.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
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}