Commit 4ba5e2812c
Unsigned
Layout: unified · split
gitbay/MRs/MRModels.swift +74 −2
| @@ -101,9 +101,12 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 101 | 101 | let reviewRequests: [String] |
| 102 | 102 | let stackedOn: StackRef? |
| 103 | 103 | let stacked: [StackRef] |
| 104 | /// The merge gates, computed by the server the way `mr merge` will | |
| 105 | /// judge them. Absent from a server older than v1.16.0. | |
| 106 | let gates: Gates? | |
| 104 | 107 | |
| 105 | 108 | enum CodingKeys: String, CodingKey { |
| 106 | case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked | |
| 109 | case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked, gates | |
| 107 | 110 | case bodyFormat = "body_format" |
| 108 | 111 | case targetRef = "target_ref" |
| 109 | 112 | case headSHA = "head_sha" |
| @@ -145,10 +148,66 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 145 | 148 | reviewRequests = try container.decodeIfPresent([String].self, forKey: .reviewRequests) ?? [] |
| 146 | 149 | stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn) |
| 147 | 150 | stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? [] |
| 151 | gates = try container.decodeIfPresent(Gates.self, forKey: .gates) | |
| 148 | 152 | } |
| 149 | 153 | |
| 150 | 154 | var isOpen: Bool { state == "open" } |
| 151 | 155 | |
| 156 | /// What stands between the merge request and a merge, all at once. | |
| 157 | nonisolated struct Gates: Decodable, Sendable, Hashable { | |
| 158 | let draft: Bool | |
| 159 | let approvalsRequired: Int | |
| 160 | /// Fresh approvals from reviewers who count. | |
| 161 | let approvals: [String] | |
| 162 | let changesRequested: [String] | |
| 163 | let codeownersRequired: Bool | |
| 164 | let ownersOutstanding: [Owners] | |
| 165 | let resolvedRequired: Bool | |
| 166 | let openThreads: Int | |
| 167 | let checksRequired: Bool | |
| 168 | /// Combined check status; nil when none reported. | |
| 169 | let checks: String? | |
| 170 | let fastForward: Bool | |
| 171 | let unmet: [String] | |
| 172 | ||
| 173 | var isMet: Bool { unmet.isEmpty } | |
| 174 | ||
| 175 | enum CodingKeys: String, CodingKey { | |
| 176 | case draft, approvals, checks, unmet | |
| 177 | case approvalsRequired = "approvals_required" | |
| 178 | case changesRequested = "changes_requested" | |
| 179 | case codeownersRequired = "codeowners_required" | |
| 180 | case ownersOutstanding = "owners_outstanding" | |
| 181 | case resolvedRequired = "resolved_required" | |
| 182 | case openThreads = "open_threads" | |
| 183 | case checksRequired = "checks_required" | |
| 184 | case fastForward = "fast_forward" | |
| 185 | } | |
| 186 | ||
| 187 | init(from decoder: Decoder) throws { | |
| 188 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 189 | draft = try c.decodeIfPresent(Bool.self, forKey: .draft) ?? false | |
| 190 | approvalsRequired = try c.decodeIfPresent(Int.self, forKey: .approvalsRequired) ?? 0 | |
| 191 | approvals = try c.decodeIfPresent([String].self, forKey: .approvals) ?? [] | |
| 192 | changesRequested = try c.decodeIfPresent([String].self, forKey: .changesRequested) ?? [] | |
| 193 | codeownersRequired = try c.decodeIfPresent(Bool.self, forKey: .codeownersRequired) ?? false | |
| 194 | ownersOutstanding = try c.decodeIfPresent([Owners].self, forKey: .ownersOutstanding) ?? [] | |
| 195 | resolvedRequired = try c.decodeIfPresent(Bool.self, forKey: .resolvedRequired) ?? false | |
| 196 | openThreads = try c.decodeIfPresent(Int.self, forKey: .openThreads) ?? 0 | |
| 197 | checksRequired = try c.decodeIfPresent(Bool.self, forKey: .checksRequired) ?? false | |
| 198 | checks = try c.decodeIfPresent(String.self, forKey: .checks) | |
| 199 | fastForward = try c.decodeIfPresent(Bool.self, forKey: .fastForward) ?? false | |
| 200 | unmet = try c.decodeIfPresent([String].self, forKey: .unmet) ?? [] | |
| 201 | } | |
| 202 | ||
| 203 | /// Files whose owners have not approved, and who those owners are. | |
| 204 | nonisolated struct Owners: Decodable, Sendable, Hashable, Identifiable { | |
| 205 | let files: [String] | |
| 206 | let owners: [String] | |
| 207 | var id: String { files.joined(separator: "\u{1f}") } | |
| 208 | } | |
| 209 | } | |
| 210 | ||
| 152 | 211 | nonisolated struct Check: Decodable, Sendable, Hashable { |
| 153 | 212 | let context: String |
| 154 | 213 | let state: String |
| @@ -192,13 +251,26 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | ||
| 192 | 251 | let verdict: String |
| 193 | 252 | /// The review predates the current head — it approved older code. |
| 194 | 253 | let stale: Bool |
| 254 | /// Whether the merge gates count this verdict: a reader may review | |
| 255 | /// a public merge request, but only a writer decides it. Absent | |
| 256 | /// before v1.16.0, when only writers could review at all. | |
| 257 | let counts: Bool | |
| 195 | 258 | let createdAt: Date? |
| 196 | 259 | |
| 197 | 260 | enum CodingKeys: String, CodingKey { |
| 198 | case reviewer, verdict, stale | |
| 261 | case reviewer, verdict, stale, counts | |
| 199 | 262 | case createdAt = "created_at" |
| 200 | 263 | } |
| 201 | 264 | |
| 265 | init(from decoder: Decoder) throws { | |
| 266 | let c = try decoder.container(keyedBy: CodingKeys.self) | |
| 267 | reviewer = try c.decode(String.self, forKey: .reviewer) | |
| 268 | verdict = try c.decode(String.self, forKey: .verdict) | |
| 269 | stale = try c.decodeIfPresent(Bool.self, forKey: .stale) ?? false | |
| 270 | counts = try c.decodeIfPresent(Bool.self, forKey: .counts) ?? true | |
| 271 | createdAt = try c.decodeIfPresent(Date.self, forKey: .createdAt) | |
| 272 | } | |
| 273 | ||
| 202 | 274 | var id: String { reviewer } |
| 203 | 275 | } |
| 204 | 276 | } |
gitbay/Views/MRs/MRView.swift +54
| @@ -53,6 +53,9 @@ struct MRView: View { | ||
| 53 | 53 | if let checks = mr.checks, !checks.isEmpty { |
| 54 | 54 | checksSection(checks, combined: mr.checksCombined) |
| 55 | 55 | } |
| 56 | if let gates = mr.gates, mr.isOpen { | |
| 57 | gatesSection(gates) | |
| 58 | } | |
| 56 | 59 | if let reviews = mr.reviews, !reviews.isEmpty { |
| 57 | 60 | reviewsSection(reviews) |
| 58 | 61 | } |
| @@ -375,6 +378,54 @@ struct MRView: View { | ||
| 375 | 378 | } |
| 376 | 379 | } |
| 377 | 380 | |
| 381 | /// The merge gates as the server will judge them, each row met or | |
| 382 | /// not, so a refusal is never the first time a rule is seen. | |
| 383 | private func gatesSection(_ gates: MRDetail.Gates) -> some View { | |
| 384 | Section { | |
| 385 | if gates.draft { | |
| 386 | gateRow(false, "Draft — not asking to merge") | |
| 387 | } | |
| 388 | if gates.approvalsRequired > 0 { | |
| 389 | gateRow(gates.approvals.count >= gates.approvalsRequired, | |
| 390 | "\(gates.approvals.count) of \(gates.approvalsRequired) approval\(gates.approvalsRequired == 1 ? "" : "s")" | |
| 391 | + (gates.approvals.isEmpty ? "" : " — " + gates.approvals.joined(separator: ", "))) | |
| 392 | } | |
| 393 | if !gates.changesRequested.isEmpty { | |
| 394 | gateRow(false, "Changes requested by " + gates.changesRequested.joined(separator: ", ")) | |
| 395 | } | |
| 396 | if gates.codeownersRequired { | |
| 397 | if gates.ownersOutstanding.isEmpty { | |
| 398 | gateRow(true, "Code owners approved") | |
| 399 | } else { | |
| 400 | ForEach(gates.ownersOutstanding) { owners in | |
| 401 | gateRow(false, owners.files.joined(separator: ", ") + " — " + owners.owners.joined(separator: ", ")) | |
| 402 | } | |
| 403 | } | |
| 404 | } | |
| 405 | if gates.resolvedRequired { | |
| 406 | gateRow(gates.openThreads == 0, | |
| 407 | gates.openThreads == 0 ? "Threads resolved" : "\(gates.openThreads) thread\(gates.openThreads == 1 ? "" : "s") open") | |
| 408 | } | |
| 409 | if gates.checksRequired { | |
| 410 | gateRow(gates.checks == "success", "Checks " + (gates.checks ?? "not reported")) | |
| 411 | } | |
| 412 | gateRow(gates.fastForward, gates.fastForward ? "Fast-forward possible" : "Behind the target — rebase first") | |
| 413 | } header: { | |
| 414 | Text("Gates") | |
| 415 | } footer: { | |
| 416 | Text(gates.isMet ? "Ready to merge." : "Unmet: " + gates.unmet.joined(separator: ", ")) | |
| 417 | } | |
| 418 | } | |
| 419 | ||
| 420 | private func gateRow(_ met: Bool, _ text: String) -> some View { | |
| 421 | HStack(alignment: .firstTextBaseline) { | |
| 422 | Image(systemName: met ? "checkmark.circle.fill" : "circle") | |
| 423 | .foregroundStyle(met ? Color.gbOK : Color.gbWarn) | |
| 424 | Text(text) | |
| 425 | .font(.gbSans(.subheadline)) | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 378 | 429 | private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { |
| 379 | 430 | Section("Reviews") { |
| 380 | 431 | ForEach(reviews) { review in |
| @@ -389,6 +440,9 @@ struct MRView: View { | ||
| 389 | 440 | if review.stale { |
| 390 | 441 | GBChip("stale", .gbWarn) |
| 391 | 442 | } |
| 443 | if !review.counts { | |
| 444 | GBChip("advisory", .secondary) | |
| 445 | } | |
| 392 | 446 | } |
| 393 | 447 | timing(review.createdAt) |
| 394 | 448 | } |
gitbayTests/MRViewModelTests.swift +43
| @@ -243,6 +243,49 @@ struct MRDetailViewModelTests { | ||
| 243 | 243 | #expect(model.revisions.isEmpty) |
| 244 | 244 | } |
| 245 | 245 | |
| 246 | /// v1.16.0: `mr show` carries the merge gates, and each review says | |
| 247 | /// whether it counts. Both decode; absent gates decode to nil. | |
| 248 | @Test func gatesAndReviewCountsDecode() async throws { | |
| 249 | let (client, stub) = try makeClient() | |
| 250 | stub.enqueue(.init(status: 200, json: """ | |
| 251 | {"protocol_version":1,"data":{"number":9,"title":"gates","state":"open",\ | |
| 252 | "author":"dave","source":"fix","target_ref":"main","head_sha":"ccdd",\ | |
| 253 | "created_at":"2026-09-08T10:00:00.000Z",\ | |
| 254 | "reviews":[{"reviewer":"carol","verdict":"approve","stale":false,"counts":false,\ | |
| 255 | "created_at":"2026-09-08T10:30:00.000Z"}],\ | |
| 256 | "gates":{"approvals_required":1,"approvals":[],"codeowners_required":true,\ | |
| 257 | "owners_outstanding":[{"files":["widget.go"],"owners":["alice"]}],\ | |
| 258 | "resolved_required":true,"open_threads":2,"checks_required":false,\ | |
| 259 | "fast_forward":false,"unmet":["approvals","codeowners","threads","fast-forward"]}},\ | |
| 260 | "exit_code":0} | |
| 261 | """, match: "argv=show")) | |
| 262 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 9) | |
| 263 | ||
| 264 | await model.load() | |
| 265 | ||
| 266 | let mr = try #require(model.state.value) | |
| 267 | let gates = try #require(mr.gates) | |
| 268 | #expect(gates.approvalsRequired == 1) | |
| 269 | #expect(gates.approvals.isEmpty) | |
| 270 | #expect(gates.codeownersRequired) | |
| 271 | #expect(gates.ownersOutstanding.first?.files == ["widget.go"]) | |
| 272 | #expect(gates.ownersOutstanding.first?.owners == ["alice"]) | |
| 273 | #expect(gates.openThreads == 2) | |
| 274 | #expect(!gates.fastForward) | |
| 275 | #expect(gates.unmet == ["approvals", "codeowners", "threads", "fast-forward"]) | |
| 276 | #expect(!gates.isMet) | |
| 277 | #expect(mr.reviews?.first?.counts == false) | |
| 278 | } | |
| 279 | ||
| 280 | @Test func aServerWithoutGatesDecodesToNoGatesAndCountingReviews() async throws { | |
| 281 | let (model, _) = try await loadedModel() | |
| 282 | ||
| 283 | let mr = try #require(model.state.value) | |
| 284 | #expect(mr.gates == nil) | |
| 285 | // A review that predates `counts` counted, since only writers could review. | |
| 286 | #expect(mr.reviews?.first?.counts == true) | |
| 287 | } | |
| 288 | ||
| 246 | 289 | @Test func loadsHeaderDiffAndThreads() async throws { |
| 247 | 290 | let (model, _) = try await loadedModel() |
| 248 | 291 | |
gitbayUITests/LiveSmokeUITests.swift +4
| @@ -244,6 +244,10 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 244 | 244 | XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list") |
| 245 | 245 | createdRow.tap() |
| 246 | 246 | |
| 247 | // The gates are shown before any merge is tried. | |
| 248 | let gates = header("Gates") | |
| 249 | XCTAssertTrue(scrollTo(gates, swipes: 8), "gates section missing") | |
| 250 | ||
| 247 | 251 | // A fresh MR has one revision; the section lists it. |
| 248 | 252 | let revisions = header("Revisions") |
| 249 | 253 | XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing") |