summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/App/RootView.swift3
-rw-r--r--Hutch/Views/Lookup/LookupView.swift2
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailView.swift248
-rw-r--r--Hutch/Views/Patchsets/PatchsetDetailViewModel.swift293
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift124
-rw-r--r--HutchTests/PatchsetTests.swift141
6 files changed, 810 insertions, 1 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 1ae4651..59d7caa 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -439,6 +439,7 @@ enum MoreRoute: Hashable {
case projectDashboard(id: String, title: String?)
case mailingList(InboxMailingListReference)
case thread(InboxThreadSummary)
+ case patchset(id: Int, listName: String?)
case manPageBrowser
case manPage(URL)
}
@@ -472,6 +473,8 @@ private struct MoreNavigationRoot: View {
ProjectDashboardDeepLinkView(projectID: id, title: title)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
+ case .patchset(let id, let listName):
+ PatchsetDetailView(patchsetID: id, listName: listName)
case .thread(let thread):
ThreadDetailView(
thread: thread,
diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift
index 2a26282..b52bb3f 100644
--- a/Hutch/Views/Lookup/LookupView.swift
+++ b/Hutch/Views/Lookup/LookupView.swift
@@ -465,6 +465,8 @@ struct LookupView: View {
ProjectDashboardDeepLinkView(projectID: id, title: title)
case .mailingList(let mailingList):
MailingListDetailView(mailingList: mailingList)
+ case .patchset(let id, let listName):
+ PatchsetDetailView(patchsetID: id, listName: listName)
case .thread(let thread):
ThreadDetailView(
thread: thread,
diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift
new file mode 100644
index 0000000..d76aab4
--- /dev/null
+++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift
@@ -0,0 +1,248 @@
+import SwiftUI
+
+struct PatchsetDetailView: View {
+ let patchsetID: Int
+ let listName: String?
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: PatchsetDetailViewModel?
+ @State private var showStatusPicker = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading Patchset…")
+ }
+ }
+ .navigationTitle("Patchset")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if let viewModel, viewModel.patchset != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ actionsMenu(viewModel)
+ }
+ }
+ }
+ .task {
+ let model = viewModel ?? PatchsetDetailViewModel(patchsetID: patchsetID, client: appState.client)
+ viewModel = model
+ await model.loadPatchset()
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PatchsetDetailViewModel) -> some View {
+ if viewModel.isLoading && viewModel.patchset == nil {
+ SRHTLoadingStateView(message: "Loading Patchset…")
+ } else if let patchset = viewModel.patchset {
+ List {
+ headerSection(patchset)
+ if let coverLetter = patchset.coverLetter {
+ emailSection(coverLetter, title: "Cover Letter")
+ }
+ if !patchset.tools.isEmpty {
+ toolsSection(patchset)
+ }
+ patchesSection(patchset)
+ }
+ .themedList()
+ .refreshable { await viewModel.loadPatchset() }
+ .overlay {
+ if viewModel.isUpdatingStatus {
+ ProgressView()
+ }
+ }
+ .confirmationDialog(
+ "Set Status",
+ isPresented: $showStatusPicker,
+ titleVisibility: .visible
+ ) {
+ ForEach(PatchsetStatus.assignable, id: \.self) { status in
+ Button(status.displayName) {
+ Task { await viewModel.updateStatus(to: status) }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ }
+ .alert(
+ "Couldn't Update Patchset",
+ isPresented: .init(
+ get: { viewModel.error != nil },
+ set: { if !$0 { viewModel.error = nil } }
+ )
+ ) {
+ Button("OK", role: .cancel) { viewModel.error = nil }
+ } message: {
+ Text(viewModel.error ?? "")
+ }
+ } else if let error = viewModel.error {
+ SRHTErrorStateView(
+ title: "Couldn't Load Patchset",
+ message: error,
+ retryAction: { await viewModel.loadPatchset() }
+ )
+ }
+ }
+
+ // MARK: - Sections
+
+ @ViewBuilder
+ private func headerSection(_ patchset: PatchsetDetail) -> some View {
+ Section {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(patchset.subject)
+ .font(.headline)
+
+ HStack(spacing: 8) {
+ PatchsetStatusBadge(status: patchset.status)
+ if patchset.version > 1 {
+ Text("v\(patchset.version)")
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ Text("\(patchset.patches.count) patch\(patchset.patches.count == 1 ? "" : "es")")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("\(patchset.submitter.canonicalName) • \(patchset.updated.relativeDescription)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if let listName {
+ Text(listName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 2)
+ .themedRow()
+
+ // The version chain matters during review: a superseded series should
+ // usually be read at its newest version instead.
+ if let supersededBy = patchset.supersededBy {
+ NavigationLink(value: MoreRoute.patchset(id: supersededBy, listName: listName)) {
+ SwiftUI.Label("Superseded by a newer version", systemImage: "arrow.right.circle")
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+
+ if let supersedes = patchset.supersedes {
+ NavigationLink(value: MoreRoute.patchset(id: supersedes, listName: listName)) {
+ SwiftUI.Label("Revises an earlier version", systemImage: "arrow.left.circle")
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func toolsSection(_ patchset: PatchsetDetail) -> some View {
+ Section("Checks") {
+ ForEach(patchset.tools) { tool in
+ HStack(spacing: 8) {
+ Image(systemName: tool.icon.systemImage)
+ .foregroundStyle(tool.icon == .failed ? .red : .secondary)
+ Text(tool.details)
+ .font(.subheadline)
+ }
+ .themedRow()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func patchesSection(_ patchset: PatchsetDetail) -> some View {
+ ForEach(patchset.patches) { patch in
+ emailSection(patch, title: patch.seriesLabel.map { "Patch \($0)" } ?? "Patch")
+ }
+ }
+
+ @ViewBuilder
+ private func emailSection(_ email: PatchsetEmail, title: String) -> some View {
+ Section(title) {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(email.subject)
+ .font(.subheadline.weight(.semibold))
+ .textSelection(.enabled)
+
+ ForEach(Array(email.contentBlocks.enumerated()), id: \.offset) { _, block in
+ switch block {
+ case .plainText(let text):
+ Text(text)
+ .font(.body)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .fixedSize(horizontal: false, vertical: true)
+ case .diff(let diff):
+ DiffView(diff: diff)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+ .themedRow()
+ }
+ }
+
+ // MARK: - Actions
+
+ @ViewBuilder
+ private func actionsMenu(_ viewModel: PatchsetDetailViewModel) -> some View {
+ Menu {
+ Button {
+ showStatusPicker = true
+ } label: {
+ SwiftUI.Label("Set Status", systemImage: "flag")
+ }
+ .disabled(viewModel.isUpdatingStatus)
+
+ if let mbox = viewModel.patchset?.mbox {
+ Divider()
+ ShareLink(item: mbox) {
+ SwiftUI.Label("Share mbox", systemImage: "square.and.arrow.up")
+ }
+ Button {
+ appState.copyToPasteboard(mbox.absoluteString, label: "mbox URL")
+ } label: {
+ SwiftUI.Label("Copy mbox URL", systemImage: "doc.on.doc")
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .accessibilityLabel("Patchset actions")
+ }
+}
+
+// MARK: - Status Badge
+
+struct PatchsetStatusBadge: View {
+ let status: PatchsetStatus
+
+ var body: some View {
+ SwiftUI.Label(status.displayName, systemImage: status.systemImage)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(background, in: Capsule())
+ .foregroundStyle(foreground)
+ }
+
+ private var foreground: Color {
+ switch status {
+ case .applied, .approved: .green
+ case .rejected: .red
+ case .needsRevision: .orange
+ case .superseded, .unknown, .proposed: .secondary
+ }
+ }
+
+ private var background: Color {
+ foreground.opacity(0.12)
+ }
+}
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
+ }
+}
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index d466121..3933bf3 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -24,12 +24,25 @@ private struct ProjectMailingListRootPayload: Decodable, Sendable {
let id: Int
let messageID: String
let patch: InboxPatchPreview?
+ /// Null unless the thread's root email opens a patchset. `MailingList` has no
+ /// patchsets field, so this is the only way to enumerate a list's patchsets.
+ let patchset: PatchsetSummaryPayload?
+}
+
+private struct PatchsetSummaryPayload: Decodable, Sendable {
+ let id: Int
+ let subject: String
+ let version: Int
+ let prefix: String?
+ let status: PatchsetStatus
}
@Observable
@MainActor
final class MailingListDetailViewModel {
private(set) var threads: [InboxThreadSummary] = []
+ /// Patchsets on this list, derived from thread roots — see the query below.
+ private(set) var patchsets: [PatchsetSummary] = []
private(set) var isLoading = false
var error: String?
var searchText = ""
@@ -52,6 +65,13 @@ final class MailingListDetailViewModel {
id
messageID
patch { subject }
+ patchset {
+ id
+ subject
+ version
+ prefix
+ status
+ }
}
}
}
@@ -87,11 +107,46 @@ final class MailingListDetailViewModel {
threads = deduplicateThreads(
response.list.threads.results.map(makeSummary(from:))
)
+ patchsets = Self.patchsets(from: response.list.threads.results)
} catch {
self.error = "Failed to load mailing list"
}
}
+ var filteredPatchsets: [PatchsetSummary] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return patchsets }
+ return patchsets.filter { $0.subject.lowercased().contains(query) }
+ }
+
+ /// Collects the patchsets opened by these threads, newest first.
+ ///
+ /// A revised series arrives as its own thread, so the same subject can appear
+ /// at several versions; they are kept as distinct patchsets and the version
+ /// chain is shown in the detail view.
+ private nonisolated static func patchsets(
+ from threads: [ProjectMailingListThreadPayload]
+ ) -> [PatchsetSummary] {
+ var seenIDs = Set<Int>()
+ var results: [PatchsetSummary] = []
+
+ for thread in threads {
+ guard let payload = thread.root.patchset, !seenIDs.contains(payload.id) else { continue }
+ seenIDs.insert(payload.id)
+ results.append(
+ PatchsetSummary(
+ id: payload.id,
+ subject: payload.subject,
+ version: payload.version,
+ prefix: payload.prefix,
+ status: payload.status
+ )
+ )
+ }
+
+ return results
+ }
+
func markThreadRead(_ thread: InboxThreadSummary) {
let viewedAt = max(Date(), thread.lastActivityAt)
InboxReadStateStore.markViewed(viewedAt, for: thread.threadGroupingKey, defaults: defaults)
@@ -257,12 +312,25 @@ final class MailingListDetailViewModel {
}
}
+enum MailingListScope: String, CaseIterable, Hashable {
+ case threads
+ case patches
+
+ var displayName: String {
+ switch self {
+ case .threads: "Threads"
+ case .patches: "Patches"
+ }
+ }
+}
+
struct MailingListDetailView: View {
let mailingList: InboxMailingListReference
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListDetailViewModel?
@State private var pinChangeCount = 0
+ @State private var scope: MailingListScope = .threads
private var currentUserKey: String? {
appState.currentUser?.canonicalName
@@ -337,6 +405,27 @@ struct MailingListDetailView: View {
@Bindable var vm = viewModel
List {
+ // Only offered when the list actually carries patches, so discussion
+ // lists do not grow an empty tab.
+ if !viewModel.patchsets.isEmpty {
+ Picker("Scope", selection: $scope) {
+ ForEach(MailingListScope.allCases, id: \.self) { scope in
+ Text(scope.displayName).tag(scope)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
+ .themedRow()
+ }
+
+ if showingPatches(viewModel) {
+ ForEach(viewModel.filteredPatchsets) { patchset in
+ NavigationLink(value: MoreRoute.patchset(id: patchset.id, listName: mailingList.name)) {
+ PatchsetRow(patchset: patchset)
+ }
+ .themedRow()
+ }
+ } else {
ForEach(viewModel.filteredThreads) { thread in
NavigationLink {
ThreadDetailView(
@@ -373,13 +462,14 @@ struct MailingListDetailView: View {
}
}
.themedRow()
+ }
}
.themedList()
.listStyle(.plain)
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search messages"
+ prompt: showingPatches(viewModel) ? "Search patches" : "Search messages"
)
.overlay {
if viewModel.isLoading, viewModel.threads.isEmpty {
@@ -390,6 +480,10 @@ struct MailingListDetailView: View {
message: error,
retryAction: { await viewModel.loadThreads() }
)
+ } else if showingPatches(viewModel) {
+ if !viewModel.patchsets.isEmpty, viewModel.filteredPatchsets.isEmpty {
+ ContentUnavailableView.search(text: viewModel.searchText)
+ }
} else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty {
ContentUnavailableView.search(text: viewModel.searchText)
} else if viewModel.threads.isEmpty {
@@ -405,6 +499,34 @@ struct MailingListDetailView: View {
}
.srhtErrorBanner(error: $vm.error)
}
+
+ private func showingPatches(_ viewModel: MailingListDetailViewModel) -> Bool {
+ scope == .patches && !viewModel.patchsets.isEmpty
+ }
+}
+
+struct PatchsetRow: View {
+ let patchset: PatchsetSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(patchset.subject)
+ .font(.subheadline.weight(.medium))
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ PatchsetStatusBadge(status: patchset.status)
+ if let versionLabel = patchset.versionLabel {
+ Text(versionLabel)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(patchset.subject), \(patchset.status.displayName)")
+ }
}
struct ProjectMailingListView: View {
diff --git a/HutchTests/PatchsetTests.swift b/HutchTests/PatchsetTests.swift
new file mode 100644
index 0000000..945131f
--- /dev/null
+++ b/HutchTests/PatchsetTests.swift
@@ -0,0 +1,141 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct PatchsetStatusTests {
+
+ @Test
+ func statusRawValuesMatchTheGraphQLEnum() {
+ // lists.sr.ht's PatchsetStatus enum values, which are sent verbatim to
+ // updatePatchset.
+ #expect(PatchsetStatus.unknown.rawValue == "UNKNOWN")
+ #expect(PatchsetStatus.proposed.rawValue == "PROPOSED")
+ #expect(PatchsetStatus.needsRevision.rawValue == "NEEDS_REVISION")
+ #expect(PatchsetStatus.superseded.rawValue == "SUPERSEDED")
+ #expect(PatchsetStatus.approved.rawValue == "APPROVED")
+ #expect(PatchsetStatus.rejected.rawValue == "REJECTED")
+ #expect(PatchsetStatus.applied.rawValue == "APPLIED")
+ }
+
+ @Test
+ func assignableStatusesExcludeServerManagedOnes() {
+ // UNKNOWN is a sentinel and SUPERSEDED is set by the server when a newer
+ // version lands, so neither should be offered as a reviewer choice.
+ #expect(!PatchsetStatus.assignable.contains(.unknown))
+ #expect(!PatchsetStatus.assignable.contains(.superseded))
+ #expect(PatchsetStatus.assignable.contains(.approved))
+ #expect(PatchsetStatus.assignable.contains(.rejected))
+ #expect(PatchsetStatus.assignable.contains(.applied))
+ #expect(PatchsetStatus.assignable.contains(.needsRevision))
+ #expect(PatchsetStatus.assignable.contains(.proposed))
+ }
+
+ @Test
+ func openStatusesAreThoseAwaitingADecision() {
+ #expect(PatchsetStatus.proposed.isOpen)
+ #expect(PatchsetStatus.needsRevision.isOpen)
+ #expect(!PatchsetStatus.applied.isOpen)
+ #expect(!PatchsetStatus.rejected.isOpen)
+ #expect(!PatchsetStatus.superseded.isOpen)
+ }
+
+ @Test
+ func statusDecodesFromTheWireFormat() throws {
+ let decoded = try JSONDecoder().decode(PatchsetStatus.self, from: Data("\"NEEDS_REVISION\"".utf8))
+ #expect(decoded == .needsRevision)
+ }
+}
+
+struct PatchsetSummaryTests {
+
+ @Test
+ func versionLabelIsHiddenForFirstVersion() {
+ let summary = PatchsetSummary(
+ id: 1,
+ subject: "[PATCH] fix the thing",
+ version: 1,
+ prefix: nil,
+ status: .proposed
+ )
+
+ #expect(summary.versionLabel == nil)
+ }
+
+ @Test
+ func versionLabelIsShownForRevisions() {
+ let summary = PatchsetSummary(
+ id: 1,
+ subject: "[PATCH v3] fix the thing",
+ version: 3,
+ prefix: nil,
+ status: .proposed
+ )
+
+ #expect(summary.versionLabel == "v3")
+ }
+}
+
+@MainActor
+struct PatchsetOrderingTests {
+
+ private func makePatch(id: Int, index: Int?, count: Int?) -> PatchsetEmail {
+ PatchsetEmail(
+ id: id,
+ subject: "patch \(id)",
+ date: nil,
+ sender: Entity(canonicalName: "~someone"),
+ contentBlocks: [],
+ index: index,
+ count: count
+ )
+ }
+
+ @Test
+ func patchesAreOrderedBySeriesIndexNotReceiptOrder() {
+ let patches = [
+ makePatch(id: 30, index: 3, count: 3),
+ makePatch(id: 10, index: 1, count: 3),
+ makePatch(id: 20, index: 2, count: 3)
+ ]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.map(\.index) == [1, 2, 3])
+ }
+
+ @Test
+ func unindexedPatchesAreKeptAtTheEndRatherThanDropped() {
+ let patches = [
+ makePatch(id: 99, index: nil, count: nil),
+ makePatch(id: 20, index: 2, count: 2),
+ makePatch(id: 10, index: 1, count: 2)
+ ]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.count == 3)
+ #expect(ordered.map(\.index) == [1, 2, nil])
+ }
+
+ @Test
+ func orderingIsStableForASingleUnindexedPatch() {
+ // A lone patch with no [PATCH n/m] prefix is the common one-off case.
+ let patches = [makePatch(id: 1, index: nil, count: nil)]
+
+ let ordered = PatchsetDetailViewModel.orderPatches(patches)
+
+ #expect(ordered.map(\.id) == [1])
+ }
+
+ @Test
+ func seriesLabelIsHiddenForSinglePatchSeries() {
+ let patch = makePatch(id: 1, index: 1, count: 1)
+ #expect(patch.seriesLabel == nil)
+ }
+
+ @Test
+ func seriesLabelShowsPositionForMultiPatchSeries() {
+ let patch = makePatch(id: 1, index: 2, count: 5)
+ #expect(patch.seriesLabel == "2/5")
+ }
+}