Commit 458e188c63

458e188c638702c7771bc534d113bd21d9b77089

parent: 64dbbfe57d

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:27 UTC

Revisions and range-diff on a merge request (!72)

mr revisions loads beside the threads and lists every head the MR has had; a revision after the first links to mr range-diff from the one before it, rendered as text. The existing write tests located the POST by position after three reads; they now look it up by method.

Layout: unified · split

gitbay/ContentView.swift +2
@@ -130,6 +130,8 @@ private struct RouteDestinations: ViewModifier {
130130 MRView(client: client, repo: repo, number: number)
131131 case .diff(let repo, let number):
132132 DiffView(client: client, repo: repo, number: number)
133 case .rangeDiff(let repo, let number, let from, let to):
134 RangeDiffView(client: client, repo: repo, number: number, from: from, to: to)
133135 }
134136 }
135137
gitbay/MRs/MRDetailViewModel.swift +3
@@ -11,6 +11,7 @@ final class MRDetailViewModel {
1111 private(set) var state: LoadState<MRDetail> = .loading
1212 private(set) var diff: UnifiedDiff?
1313 private(set) var threads: [ReviewThread] = []
14 private(set) var revisions: [MRRevision] = []
1415 /// A write failed; the server's sentence, shown until the next action.
1516 private(set) var actionError: String?
1617 private(set) var working = false
@@ -39,8 +40,10 @@ final class MRDetailViewModel {
3940 // header usable rather than sinking the screen.
4041 async let diffText = try? client.readText(["mr", "diff"] + ref)
4142 async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self)
43 async let revisionList = try? client.readList(["mr", "revisions"] + ref, of: MRRevision.self)
4244 diff = (await diffText).map(UnifiedDiff.parse)
4345 threads = await threadList ?? []
46 revisions = await revisionList ?? []
4447 }
4548
4649 var unresolvedCount: Int {
gitbay/MRs/MRModels.swift +19
@@ -52,6 +52,25 @@ nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable {
5252 var isOpen: Bool { state == "open" }
5353}
5454
55/// One row of `mr revisions`: a head the merge request has had. `n`
56/// counts from the first push; `current` marks the head it has now.
57nonisolated struct MRRevision: Decodable, Sendable, Hashable, Identifiable {
58 let n: Int
59 let sha: String
60 let baseSHA: String?
61 let createdAt: String
62 let current: Bool?
63
64 enum CodingKeys: String, CodingKey {
65 case n, sha, current
66 case baseSHA = "base_sha"
67 case createdAt = "created_at"
68 }
69
70 var id: Int { n }
71 var isCurrent: Bool { current ?? false }
72}
73
5574/// The whole of `mr show`.
5675nonisolated struct MRDetail: Decodable, Sendable, Hashable {
5776 let number: Int64
gitbay/MRs/RangeDiffViewModel.swift added +36
@@ -0,0 +1,36 @@
1import Foundation
2import Observation
3
4/// `mr range-diff <owner/name> <n> --from <sha> --to <sha>` — git's
5/// range-diff between two heads the merge request has had, as text.
6@Observable
7@MainActor
8final class RangeDiffViewModel {
9
10 private(set) var state: LoadState<String> = .loading
11
12 private let client: GitbayClient
13 let repoPath: String
14 let number: Int64
15 let from: String
16 let to: String
17
18 init(client: GitbayClient, repoPath: String, number: Int64, from: String, to: String) {
19 self.client = client
20 self.repoPath = repoPath
21 self.number = number
22 self.from = from
23 self.to = to
24 }
25
26 func load() async {
27 state = .loading
28 do {
29 let text = try await client.readText(
30 ["mr", "range-diff", repoPath, String(number), "--from", from, "--to", to])
31 state = text.isEmpty ? .empty("The two revisions carry the same commits.") : .loaded(text)
32 } catch {
33 state = .from(error)
34 }
35 }
36}
gitbay/Views/MRs/MRListView.swift +1
@@ -239,4 +239,5 @@ nonisolated enum MRRoute: Hashable {
239239 case list(repo: String)
240240 case mr(repo: String, number: Int64)
241241 case diff(repo: String, number: Int64)
242 case rangeDiff(repo: String, number: Int64, from: String, to: String)
242243}
gitbay/Views/MRs/MRView.swift +36
@@ -43,6 +43,9 @@ struct MRView: View {
4343 stackSection(mr)
4444 reviewersSection(mr)
4545 diffSection
46 if !model.revisions.isEmpty {
47 revisionsSection
48 }
4649
4750 if let commits = mr.commits, !commits.isEmpty {
4851 commitsSection(commits)
@@ -238,6 +241,39 @@ struct MRView: View {
238241 }
239242 }
240243
244 /// Every head the merge request has had. A revision after the first
245 /// links to the range-diff from the one before it.
246 private var revisionsSection: some View {
247 Section("Revisions") {
248 ForEach(Array(model.revisions.enumerated()), id: \.element.id) { index, revision in
249 let row = HStack {
250 Text("v\(revision.n)")
251 .font(.gbSans(.subheadline).weight(.medium))
252 Text(String(revision.sha.prefix(10)))
253 .font(.gbMono(.caption))
254 .foregroundStyle(.secondary)
255 Spacer()
256 if revision.isCurrent {
257 GBChip("current", .secondary)
258 }
259 Text(revision.createdAt.prefix(10))
260 .font(.gbSans(.caption))
261 .foregroundStyle(.tertiary)
262 }
263 if index > 0 {
264 NavigationLink(value: MRRoute.rangeDiff(
265 repo: model.repoPath, number: model.number,
266 from: model.revisions[index - 1].sha, to: revision.sha)
267 ) {
268 row
269 }
270 } else {
271 row
272 }
273 }
274 }
275 }
276
241277 private func reviewersSection(_ mr: MRDetail) -> some View {
242278 Section("Reviewers") {
243279 reviewerFlow(mr.reviewRequests)
gitbay/Views/MRs/RangeDiffView.swift added +32
@@ -0,0 +1,32 @@
1import SwiftUI
2
3/// git's range-diff between two revisions of a merge request, as the
4/// command prints it — the same monospaced text treatment as a build log.
5struct RangeDiffView: View {
6
7 @State private var model: RangeDiffViewModel
8
9 init(client: GitbayClient, repo: String, number: Int64, from: String, to: String) {
10 _model = State(initialValue: RangeDiffViewModel(
11 client: client, repoPath: repo, number: number, from: from, to: to))
12 }
13
14 var body: some View {
15 ZStack {
16 Color.clear
17 if let text = model.state.value {
18 ScrollView([.horizontal, .vertical]) {
19 Text(text)
20 .font(.gbMono(.caption2))
21 .frame(maxWidth: .infinity, alignment: .leading)
22 .padding(12)
23 .textSelection(.enabled)
24 }
25 }
26 }
27 .overlay { LoadStateOverlay(state: model.state) }
28 .navigationTitle("\(model.from.prefix(7))…\(model.to.prefix(7))")
29 .navigationBarTitleDisplayMode(.inline)
30 .task { await model.load() }
31 }
32}
gitbayTests/MRViewModelTests.swift +73 −5
@@ -207,6 +207,42 @@ struct MRDetailViewModelTests {
207207 return (model, stub)
208208 }
209209
210 @Test func revisionsLoadBesideTheThreads() async throws {
211 let (client, stub) = try makeClient()
212 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
213 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
214 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
215 stub.enqueue(.init(status: 200, json: """
216 {"protocol_version":1,"data":[\
217 {"n":1,"sha":"aaaa1111aaaa1111","created_at":"2026-09-01T10:00:00.000Z"},\
218 {"n":2,"sha":"bbbb2222bbbb2222","base_sha":"aaaa1111aaaa1111",\
219 "created_at":"2026-09-02T10:00:00.000Z","current":true}],"exit_code":0}
220 """, match: "argv=revisions"))
221 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
222
223 await model.load()
224
225 #expect(model.revisions.map(\.n) == [1, 2])
226 #expect(model.revisions.last?.isCurrent == true)
227 #expect(model.revisions.first?.isCurrent == false)
228 #expect(model.revisions.last?.baseSHA == "aaaa1111aaaa1111")
229 #expect(stub.seen.contains { $0.url.query()?.contains("argv=mr&argv=revisions&argv=krz/gitbay&argv=7") == true })
230 }
231
232 @Test func aFailedRevisionsReadLeavesTheHeaderUsable() async throws {
233 let (client, stub) = try makeClient()
234 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
235 stub.enqueue(.init(status: 200, json: diffEnvelope(), match: "argv=diff"))
236 stub.enqueue(.init(status: 200, json: threadsJSON, match: "argv=threads"))
237 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#, match: "argv=revisions"))
238 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
239
240 await model.load()
241
242 #expect(model.state.value != nil)
243 #expect(model.revisions.isEmpty)
244 }
245
210246 @Test func loadsHeaderDiffAndThreads() async throws {
211247 let (model, _) = try await loadedModel()
212248
@@ -265,7 +301,7 @@ struct MRDetailViewModelTests {
265301
266302 await model.review(.approve)
267303
268 let write = try #require(stub.seen.dropFirst(3).first)
304 let write = try #require(stub.seen.first { $0.method == "POST" })
269305 #expect(write.method == "POST")
270306 #expect(write.url.path() == "/api/v1/cmd")
271307 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
@@ -283,7 +319,7 @@ struct MRDetailViewModelTests {
283319
284320 await model.comment("long review text\nwith lines")
285321
286 let write = try #require(stub.seen.dropFirst(3).first)
322 let write = try #require(stub.seen.first { $0.method == "POST" })
287323 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
288324 #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"])
289325 #expect(body["stdin"] as? String == "long review text\nwith lines")
@@ -299,7 +335,8 @@ struct MRDetailViewModelTests {
299335 #expect(model.actionError == "merge blocked: 1 review thread unresolved")
300336 // The refusal did not wipe the loaded screen.
301337 #expect(model.state.value != nil)
302 #expect(stub.seen.count == 4)
338 // show, diff, threads, revisions, then the refused write.
339 #expect(stub.seen.count == 5)
303340 }
304341
305342 @Test func resolveTargetsTheThreadID() async throws {
@@ -312,7 +349,7 @@ struct MRDetailViewModelTests {
312349
313350 await model.setResolved(thread, true)
314351
315 let write = try #require(stub.seen.dropFirst(3).first)
352 let write = try #require(stub.seen.first { $0.method == "POST" })
316353 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
317354 #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"])
318355 }
@@ -327,7 +364,7 @@ struct MRDetailViewModelTests {
327364
328365 await model.reply(to: thread, "because 5xx is transient")
329366
330 let write = try #require(stub.seen.dropFirst(3).first)
367 let write = try #require(stub.seen.first { $0.method == "POST" })
331368 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
332369 #expect(body["argv"] as? [String] ==
333370 ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"])
@@ -398,3 +435,34 @@ struct ThreadAnchoringTests {
398435 #expect(!diff.anchors(try thread(path: "main.go", line: 999)))
399436 }
400437}
438
439@MainActor
440struct RangeDiffViewModelTests {
441
442 @Test func readsTheRangeDiffBetweenTwoRevisions() async throws {
443 let (client, stub) = try makeClient()
444 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"1: aaaa111 = 1: bbbb222 client: envelope\n","exit_code":0}"#))
445 let model = RangeDiffViewModel(client: client, repoPath: "krz/gitbay", number: 7,
446 from: "aaaa1111aaaa1111", to: "bbbb2222bbbb2222")
447
448 await model.load()
449
450 #expect(model.state.value == "1: aaaa111 = 1: bbbb222 client: envelope\n")
451 #expect(stub.seen.first?.url.query() ==
452 "argv=mr&argv=range-diff&argv=krz/gitbay&argv=7&argv=--from&argv=aaaa1111aaaa1111&argv=--to&argv=bbbb2222bbbb2222")
453 }
454
455 @Test func anEmptyRangeDiffIsAnEmptyState() async throws {
456 let (client, stub) = try makeClient()
457 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"output":"","exit_code":0}"#))
458 let model = RangeDiffViewModel(client: client, repoPath: "krz/gitbay", number: 7,
459 from: "a", to: "b")
460
461 await model.load()
462
463 guard case .empty = model.state else {
464 Issue.record("expected .empty, got \(model.state)")
465 return
466 }
467 }
468}
gitbayUITests/LiveSmokeUITests.swift +5
@@ -241,6 +241,11 @@ final class LiveSmokeUITests: XCTestCase {
241241 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
242242 createdRow.tap()
243243
244 // A fresh MR has one revision; the section lists it.
245 let revisions = app.staticTexts["Revisions"].firstMatch
246 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")
247 XCTAssertTrue(app.staticTexts["v1"].firstMatch.exists, "first revision not listed")
248
244249 app.descendants(matching: .any).matching(identifier: "mr-actions-menu")
245250 .firstMatch.tap()
246251 let close = app.buttons["Close"].firstMatch