Commit 458e188c63
Unsigned
Layout: unified · split
gitbay/ContentView.swift +2
| @@ -130,6 +130,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 130 | 130 | MRView(client: client, repo: repo, number: number) |
| 131 | 131 | case .diff(let repo, let number): |
| 132 | 132 | 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) | |
| 133 | 135 | } |
| 134 | 136 | } |
| 135 | 137 | |
gitbay/MRs/MRDetailViewModel.swift +3
| @@ -11,6 +11,7 @@ final class MRDetailViewModel { | ||
| 11 | 11 | private(set) var state: LoadState<MRDetail> = .loading |
| 12 | 12 | private(set) var diff: UnifiedDiff? |
| 13 | 13 | private(set) var threads: [ReviewThread] = [] |
| 14 | private(set) var revisions: [MRRevision] = [] | |
| 14 | 15 | /// A write failed; the server's sentence, shown until the next action. |
| 15 | 16 | private(set) var actionError: String? |
| 16 | 17 | private(set) var working = false |
| @@ -39,8 +40,10 @@ final class MRDetailViewModel { | ||
| 39 | 40 | // header usable rather than sinking the screen. |
| 40 | 41 | async let diffText = try? client.readText(["mr", "diff"] + ref) |
| 41 | 42 | 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) | |
| 42 | 44 | diff = (await diffText).map(UnifiedDiff.parse) |
| 43 | 45 | threads = await threadList ?? [] |
| 46 | revisions = await revisionList ?? [] | |
| 44 | 47 | } |
| 45 | 48 | |
| 46 | 49 | var unresolvedCount: Int { |
gitbay/MRs/MRModels.swift +19
| @@ -52,6 +52,25 @@ nonisolated struct MergeRequest: Decodable, Sendable, Hashable, Identifiable { | ||
| 52 | 52 | var isOpen: Bool { state == "open" } |
| 53 | 53 | } |
| 54 | 54 | |
| 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. | |
| 57 | nonisolated 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 | ||
| 55 | 74 | /// The whole of `mr show`. |
| 56 | 75 | nonisolated struct MRDetail: Decodable, Sendable, Hashable { |
| 57 | 76 | let number: Int64 |
gitbay/MRs/RangeDiffViewModel.swift added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 8 | final 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 { | ||
| 239 | 239 | case list(repo: String) |
| 240 | 240 | case mr(repo: String, number: Int64) |
| 241 | 241 | case diff(repo: String, number: Int64) |
| 242 | case rangeDiff(repo: String, number: Int64, from: String, to: String) | |
| 242 | 243 | } |
gitbay/Views/MRs/MRView.swift +36
| @@ -43,6 +43,9 @@ struct MRView: View { | ||
| 43 | 43 | stackSection(mr) |
| 44 | 44 | reviewersSection(mr) |
| 45 | 45 | diffSection |
| 46 | if !model.revisions.isEmpty { | |
| 47 | revisionsSection | |
| 48 | } | |
| 46 | 49 | |
| 47 | 50 | if let commits = mr.commits, !commits.isEmpty { |
| 48 | 51 | commitsSection(commits) |
| @@ -238,6 +241,39 @@ struct MRView: View { | ||
| 238 | 241 | } |
| 239 | 242 | } |
| 240 | 243 | |
| 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 | ||
| 241 | 277 | private func reviewersSection(_ mr: MRDetail) -> some View { |
| 242 | 278 | Section("Reviewers") { |
| 243 | 279 | reviewerFlow(mr.reviewRequests) |
gitbay/Views/MRs/RangeDiffView.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import 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. | |
| 5 | struct 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 { | ||
| 207 | 207 | return (model, stub) |
| 208 | 208 | } |
| 209 | 209 | |
| 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 | ||
| 210 | 246 | @Test func loadsHeaderDiffAndThreads() async throws { |
| 211 | 247 | let (model, _) = try await loadedModel() |
| 212 | 248 | |
| @@ -265,7 +301,7 @@ struct MRDetailViewModelTests { | ||
| 265 | 301 | |
| 266 | 302 | await model.review(.approve) |
| 267 | 303 | |
| 268 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 304 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 269 | 305 | #expect(write.method == "POST") |
| 270 | 306 | #expect(write.url.path() == "/api/v1/cmd") |
| 271 | 307 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| @@ -283,7 +319,7 @@ struct MRDetailViewModelTests { | ||
| 283 | 319 | |
| 284 | 320 | await model.comment("long review text\nwith lines") |
| 285 | 321 | |
| 286 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 322 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 287 | 323 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 288 | 324 | #expect(body["argv"] as? [String] == ["mr", "comment", "krz/gitbay", "7", "--file", "-"]) |
| 289 | 325 | #expect(body["stdin"] as? String == "long review text\nwith lines") |
| @@ -299,7 +335,8 @@ struct MRDetailViewModelTests { | ||
| 299 | 335 | #expect(model.actionError == "merge blocked: 1 review thread unresolved") |
| 300 | 336 | // The refusal did not wipe the loaded screen. |
| 301 | 337 | #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) | |
| 303 | 340 | } |
| 304 | 341 | |
| 305 | 342 | @Test func resolveTargetsTheThreadID() async throws { |
| @@ -312,7 +349,7 @@ struct MRDetailViewModelTests { | ||
| 312 | 349 | |
| 313 | 350 | await model.setResolved(thread, true) |
| 314 | 351 | |
| 315 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 352 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 316 | 353 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 317 | 354 | #expect(body["argv"] as? [String] == ["mr", "resolve", "krz/gitbay", "7", "3"]) |
| 318 | 355 | } |
| @@ -327,7 +364,7 @@ struct MRDetailViewModelTests { | ||
| 327 | 364 | |
| 328 | 365 | await model.reply(to: thread, "because 5xx is transient") |
| 329 | 366 | |
| 330 | let write = try #require(stub.seen.dropFirst(3).first) | |
| 367 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 331 | 368 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 332 | 369 | #expect(body["argv"] as? [String] == |
| 333 | 370 | ["mr", "diff-comment", "krz/gitbay", "7", "--reply", "3", "--file", "-"]) |
| @@ -398,3 +435,34 @@ struct ThreadAnchoringTests { | ||
| 398 | 435 | #expect(!diff.anchors(try thread(path: "main.go", line: 999))) |
| 399 | 436 | } |
| 400 | 437 | } |
| 438 | ||
| 439 | @MainActor | |
| 440 | struct 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 { | ||
| 241 | 241 | XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list") |
| 242 | 242 | createdRow.tap() |
| 243 | 243 | |
| 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 | ||
| 244 | 249 | app.descendants(matching: .any).matching(identifier: "mr-actions-menu") |
| 245 | 250 | .firstMatch.tap() |
| 246 | 251 | let close = app.buttons["Close"].firstMatch |