krz/hutch
an ios client for sourcehut
clone: git clone https://gitbay.org/krz/hutch.git
remove-splash-highlighter: Hutch/Views/Patchsets/PatchsetDetailViewModel.swift · raw
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct PatchsetDetailResponse: Decodable, Sendable {
6 let patchset: PatchsetDetailPayload?
7}
8
9private struct PatchsetDetailPayload: Decodable, Sendable {
10 let id: Int
11 let created: Date
12 let updated: Date
13 let subject: String
14 let version: Int
15 let prefix: String?
16 let status: PatchsetStatus
17 let submitter: Entity
18 let coverLetter: PatchsetEmailPayload?
19 let supersededBy: PatchsetReferencePayload?
20 let supersedes: PatchsetReferencePayload?
21 let patches: PatchsetPatchPage
22 let tools: [PatchsetToolPayload]
23 let mbox: URL?
24}
25
26private struct PatchsetReferencePayload: Decodable, Sendable {
27 let id: Int
28}
29
30private struct PatchsetPatchPage: Decodable, Sendable {
31 let results: [PatchsetEmailPayload]
32 let cursor: String?
33}
34
35private struct PatchsetEmailPayload: Decodable, Sendable {
36 let id: Int
37 let subject: String
38 let date: Date?
39 let sender: Entity
40 let body: String
41 let patch: PatchIndexPayload?
42}
43
44private struct PatchIndexPayload: Decodable, Sendable {
45 let index: Int?
46 let count: Int?
47}
48
49private struct PatchsetToolPayload: Decodable, Sendable {
50 let id: Int
51 let icon: PatchsetToolIcon
52 let details: String
53}
54
55private struct UpdatePatchsetResponse: Decodable, Sendable {
56 let patchset: UpdatedPatchsetPayload?
57}
58
59private struct UpdatedPatchsetPayload: Decodable, Sendable {
60 let status: PatchsetStatus
61}
62
63// MARK: - View Model
64
65@Observable
66@MainActor
67final class PatchsetDetailViewModel {
68
69 let patchsetID: Int
70
71 private(set) var patchset: PatchsetDetail?
72 private(set) var isLoading = false
73 private(set) var isUpdatingStatus = false
74 var error: String?
75
76 private let client: SRHTClient
77
78 init(patchsetID: Int, client: SRHTClient) {
79 self.patchsetID = patchsetID
80 self.client = client
81 }
82
83 // MARK: - Queries
84
85 /// `patches` is paginated, but a series is small and reviewing half of one is
86 /// worse than useless, so every page is walked before rendering.
87 private static let detailQuery = """
88 query patchset($id: Int!, $cursor: Cursor) {
89 patchset(id: $id) {
90 id
91 created
92 updated
93 subject
94 version
95 prefix
96 status
97 submitter { canonicalName }
98 supersededBy { id }
99 supersedes { id }
100 coverLetter {
101 id
102 subject
103 date
104 sender { canonicalName }
105 body
106 patch { index count }
107 }
108 patches(cursor: $cursor) {
109 results {
110 id
111 subject
112 date
113 sender { canonicalName }
114 body
115 patch { index count }
116 }
117 cursor
118 }
119 tools { id icon details }
120 mbox
121 }
122 }
123 """
124
125 private static let updateStatusMutation = """
126 mutation updatePatchset($id: Int!, $status: PatchsetStatus!) {
127 patchset: updatePatchset(id: $id, status: $status) {
128 status
129 }
130 }
131 """
132
133 // MARK: - Loading
134
135 func loadPatchset() async {
136 guard !isLoading else { return }
137 isLoading = true
138 error = nil
139 defer { isLoading = false }
140
141 do {
142 patchset = try await fetchPatchset()
143 } catch {
144 self.error = error.userFacingMessage
145 }
146 }
147
148 private func fetchPatchset() async throws -> PatchsetDetail {
149 var cursor: String?
150 var payload: PatchsetDetailPayload?
151 var patches: [PatchsetEmailPayload] = []
152
153 // Walk the patches pages, keeping the first page's patchset fields.
154 while true {
155 var variables: [String: any Sendable] = ["id": patchsetID]
156 if let cursor {
157 variables["cursor"] = cursor
158 }
159
160 let response = try await client.execute(
161 service: .lists,
162 query: Self.detailQuery,
163 variables: variables,
164 responseType: PatchsetDetailResponse.self
165 )
166
167 guard let page = response.patchset else {
168 throw SRHTError.graphQLErrors([
169 GraphQLError(message: "That patchset is no longer available.", locations: nil)
170 ])
171 }
172
173 if payload == nil {
174 payload = page
175 }
176 patches.append(contentsOf: page.patches.results)
177
178 guard let next = page.patches.cursor, !next.isEmpty else { break }
179 cursor = next
180 }
181
182 guard let payload else {
183 throw SRHTError.graphQLErrors([
184 GraphQLError(message: "That patchset is no longer available.", locations: nil)
185 ])
186 }
187
188 return PatchsetDetail(
189 id: payload.id,
190 created: payload.created,
191 updated: payload.updated,
192 subject: payload.subject,
193 version: payload.version,
194 prefix: payload.prefix,
195 status: payload.status,
196 submitter: payload.submitter,
197 coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) },
198 patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }),
199 supersededBy: payload.supersededBy?.id,
200 supersedes: payload.supersedes?.id,
201 tools: payload.tools.map {
202 PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details)
203 },
204 mbox: payload.mbox
205 )
206 }
207
208 // MARK: - Status
209
210 /// Sets the review status. Returns true on success.
211 @discardableResult
212 func updateStatus(to newStatus: PatchsetStatus) async -> Bool {
213 guard !isUpdatingStatus, let current = patchset else { return false }
214 guard newStatus != current.status else { return true }
215
216 isUpdatingStatus = true
217 error = nil
218 defer { isUpdatingStatus = false }
219
220 do {
221 let response = try await client.execute(
222 service: .lists,
223 query: Self.updateStatusMutation,
224 variables: [
225 "id": patchsetID,
226 "status": newStatus.rawValue
227 ],
228 responseType: UpdatePatchsetResponse.self
229 )
230
231 // updatePatchset is nullable: null means the server declined without
232 // erroring, so the local status must not be advanced.
233 guard let updated = response.patchset else {
234 self.error = "SourceHut did not apply that status change."
235 return false
236 }
237
238 apply(status: updated.status)
239 return true
240 } catch {
241 self.error = error.userFacingMessage
242 return false
243 }
244 }
245
246 private func apply(status: PatchsetStatus) {
247 guard let current = patchset else { return }
248 patchset = PatchsetDetail(
249 id: current.id,
250 created: current.created,
251 updated: current.updated,
252 subject: current.subject,
253 version: current.version,
254 prefix: current.prefix,
255 status: status,
256 submitter: current.submitter,
257 coverLetter: current.coverLetter,
258 patches: current.patches,
259 supersededBy: current.supersededBy,
260 supersedes: current.supersedes,
261 tools: current.tools,
262 mbox: current.mbox
263 )
264 }
265
266 // MARK: - Mapping
267
268 private nonisolated static func makeEmail(
269 from payload: PatchsetEmailPayload,
270 isPatch: Bool
271 ) -> PatchsetEmail {
272 PatchsetEmail(
273 id: payload.id,
274 subject: payload.subject,
275 date: payload.date,
276 sender: payload.sender,
277 contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch),
278 index: payload.patch?.index,
279 count: payload.patch?.count
280 )
281 }
282
283 /// Orders a series by its `[PATCH n/m]` index.
284 ///
285 /// sr.ht returns patches in receipt order, which is not series order when a
286 /// contributor's mail arrives out of sequence. Patches without an index keep
287 /// their relative position at the end rather than being dropped.
288 nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] {
289 let indexed = patches.filter { $0.index != nil }
290 let unindexed = patches.filter { $0.index == nil }
291 return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed
292 }
293}