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 /HutchTests/PatchsetTests.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 'HutchTests/PatchsetTests.swift')
| -rw-r--r-- | HutchTests/PatchsetTests.swift | 141 |
1 files changed, 141 insertions, 0 deletions
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") + } +} |
