krz/hutch

an ios client for sourcehut

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

v3.8.2: 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}