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