diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 21:48:58 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-15 21:48:58 -0500 |
| commit | 0eceec357b7ef5251ed0ae9d42b513c17380e0af (patch) | |
| tree | 98b421635cbaf5a846a460603f03765680d1da06 /Hutch/Views/Patchsets/PatchsetDetailViewModel.swift | |
| parent | ccec322f8eac4d14638f5abdae7dae7abc95eb7e (diff) | |
| download | hutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.tar.gz hutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.tar.bz2 hutch-0eceec357b7ef5251ed0ae9d42b513c17380e0af.zip | |
feat: review patchsets
Patchsets are how contributions reach sourcehut, and Hutch had no reference to
them anywhere. This adds review and triage: read a series, see its checks and
version chain, and set its status.
Two schema facts shaped the design.
MailingList exposes no patchsets field, so a list's patchsets cannot be queried
directly. They are reachable only through thread roots, so the existing threads
query now also selects root.patchset — no extra request — and the Patches tab is
derived from that. It appears only on lists that actually carry patches.
Patch carries no diff. index, count, version, prefix, subject, and trailers are
all it has; the diff exists only inside the email body. Patch bodies are split
with the same InboxThreadUtilities.segmentMessageBody the inbox uses and
rendered through the existing DiffView.
Patches are ordered by their [PATCH n/m] index rather than receipt order, since
mail arrives out of sequence. Patches with no index are kept at the end rather
than dropped, because a one-off patch has no prefix.
updatePatchset is nullable, so a null response is treated as a declined change
and the local status is left alone rather than advanced optimistically. UNKNOWN
and SUPERSEDED are not offered: the first is a sentinel, the second is set by
the server when a newer version lands.
Patch submission stays out of scope. It is a git send-email flow, not a GraphQL
mutation.
Diffstat (limited to 'Hutch/Views/Patchsets/PatchsetDetailViewModel.swift')
| -rw-r--r-- | Hutch/Views/Patchsets/PatchsetDetailViewModel.swift | 293 |
1 files changed, 293 insertions, 0 deletions
diff --git a/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift new file mode 100644 index 0000000..88af328 --- /dev/null +++ b/Hutch/Views/Patchsets/PatchsetDetailViewModel.swift @@ -0,0 +1,293 @@ +import Foundation + +// MARK: - Response types (file-private to avoid @MainActor Decodable issues) + +private struct PatchsetDetailResponse: Decodable, Sendable { + let patchset: PatchsetDetailPayload? +} + +private struct PatchsetDetailPayload: Decodable, Sendable { + let id: Int + let created: Date + let updated: Date + let subject: String + let version: Int + let prefix: String? + let status: PatchsetStatus + let submitter: Entity + let coverLetter: PatchsetEmailPayload? + let supersededBy: PatchsetReferencePayload? + let supersedes: PatchsetReferencePayload? + let patches: PatchsetPatchPage + let tools: [PatchsetToolPayload] + let mbox: URL? +} + +private struct PatchsetReferencePayload: Decodable, Sendable { + let id: Int +} + +private struct PatchsetPatchPage: Decodable, Sendable { + let results: [PatchsetEmailPayload] + let cursor: String? +} + +private struct PatchsetEmailPayload: Decodable, Sendable { + let id: Int + let subject: String + let date: Date? + let sender: Entity + let body: String + let patch: PatchIndexPayload? +} + +private struct PatchIndexPayload: Decodable, Sendable { + let index: Int? + let count: Int? +} + +private struct PatchsetToolPayload: Decodable, Sendable { + let id: Int + let icon: PatchsetToolIcon + let details: String +} + +private struct UpdatePatchsetResponse: Decodable, Sendable { + let patchset: UpdatedPatchsetPayload? +} + +private struct UpdatedPatchsetPayload: Decodable, Sendable { + let status: PatchsetStatus +} + +// MARK: - View Model + +@Observable +@MainActor +final class PatchsetDetailViewModel { + + let patchsetID: Int + + private(set) var patchset: PatchsetDetail? + private(set) var isLoading = false + private(set) var isUpdatingStatus = false + var error: String? + + private let client: SRHTClient + + init(patchsetID: Int, client: SRHTClient) { + self.patchsetID = patchsetID + self.client = client + } + + // MARK: - Queries + + /// `patches` is paginated, but a series is small and reviewing half of one is + /// worse than useless, so every page is walked before rendering. + private static let detailQuery = """ + query patchset($id: Int!, $cursor: Cursor) { + patchset(id: $id) { + id + created + updated + subject + version + prefix + status + submitter { canonicalName } + supersededBy { id } + supersedes { id } + coverLetter { + id + subject + date + sender { canonicalName } + body + patch { index count } + } + patches(cursor: $cursor) { + results { + id + subject + date + sender { canonicalName } + body + patch { index count } + } + cursor + } + tools { id icon details } + mbox + } + } + """ + + private static let updateStatusMutation = """ + mutation updatePatchset($id: Int!, $status: PatchsetStatus!) { + patchset: updatePatchset(id: $id, status: $status) { + status + } + } + """ + + // MARK: - Loading + + func loadPatchset() async { + guard !isLoading else { return } + isLoading = true + error = nil + defer { isLoading = false } + + do { + patchset = try await fetchPatchset() + } catch { + self.error = error.userFacingMessage + } + } + + private func fetchPatchset() async throws -> PatchsetDetail { + var cursor: String? + var payload: PatchsetDetailPayload? + var patches: [PatchsetEmailPayload] = [] + + // Walk the patches pages, keeping the first page's patchset fields. + while true { + var variables: [String: any Sendable] = ["id": patchsetID] + if let cursor { + variables["cursor"] = cursor + } + + let response = try await client.execute( + service: .lists, + query: Self.detailQuery, + variables: variables, + responseType: PatchsetDetailResponse.self + ) + + guard let page = response.patchset else { + throw SRHTError.graphQLErrors([ + GraphQLError(message: "That patchset is no longer available.", locations: nil) + ]) + } + + if payload == nil { + payload = page + } + patches.append(contentsOf: page.patches.results) + + guard let next = page.patches.cursor, !next.isEmpty else { break } + cursor = next + } + + guard let payload else { + throw SRHTError.graphQLErrors([ + GraphQLError(message: "That patchset is no longer available.", locations: nil) + ]) + } + + return PatchsetDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + subject: payload.subject, + version: payload.version, + prefix: payload.prefix, + status: payload.status, + submitter: payload.submitter, + coverLetter: payload.coverLetter.map { Self.makeEmail(from: $0, isPatch: false) }, + patches: Self.orderPatches(patches.map { Self.makeEmail(from: $0, isPatch: true) }), + supersededBy: payload.supersededBy?.id, + supersedes: payload.supersedes?.id, + tools: payload.tools.map { + PatchsetToolResult(id: $0.id, icon: $0.icon, details: $0.details) + }, + mbox: payload.mbox + ) + } + + // MARK: - Status + + /// Sets the review status. Returns true on success. + @discardableResult + func updateStatus(to newStatus: PatchsetStatus) async -> Bool { + guard !isUpdatingStatus, let current = patchset else { return false } + guard newStatus != current.status else { return true } + + isUpdatingStatus = true + error = nil + defer { isUpdatingStatus = false } + + do { + let response = try await client.execute( + service: .lists, + query: Self.updateStatusMutation, + variables: [ + "id": patchsetID, + "status": newStatus.rawValue + ], + responseType: UpdatePatchsetResponse.self + ) + + // updatePatchset is nullable: null means the server declined without + // erroring, so the local status must not be advanced. + guard let updated = response.patchset else { + self.error = "SourceHut did not apply that status change." + return false + } + + apply(status: updated.status) + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + private func apply(status: PatchsetStatus) { + guard let current = patchset else { return } + patchset = PatchsetDetail( + id: current.id, + created: current.created, + updated: current.updated, + subject: current.subject, + version: current.version, + prefix: current.prefix, + status: status, + submitter: current.submitter, + coverLetter: current.coverLetter, + patches: current.patches, + supersededBy: current.supersededBy, + supersedes: current.supersedes, + tools: current.tools, + mbox: current.mbox + ) + } + + // MARK: - Mapping + + private nonisolated static func makeEmail( + from payload: PatchsetEmailPayload, + isPatch: Bool + ) -> PatchsetEmail { + PatchsetEmail( + id: payload.id, + subject: payload.subject, + date: payload.date, + sender: payload.sender, + contentBlocks: InboxThreadUtilities.segmentMessageBody(payload.body, isPatch: isPatch), + index: payload.patch?.index, + count: payload.patch?.count + ) + } + + /// Orders a series by its `[PATCH n/m]` index. + /// + /// sr.ht returns patches in receipt order, which is not series order when a + /// contributor's mail arrives out of sequence. Patches without an index keep + /// their relative position at the end rather than being dropped. + nonisolated static func orderPatches(_ patches: [PatchsetEmail]) -> [PatchsetEmail] { + let indexed = patches.filter { $0.index != nil } + let unindexed = patches.filter { $0.index == nil } + return indexed.sorted { ($0.index ?? 0) < ($1.index ?? 0) } + unindexed + } +} |
