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