Commit 4ba5e2812c

4ba5e2812c8ecf1ed4bae9275d031b936620cef3

parent: 56043c8483

Unsigned

cmc <hello@cleberg.net> · 2026-09-08 03:11 UTC

Merge gates on a merge request (!87)

mr show carries a gates block since v1.16.0 (krz/gitbay#199): approvals against required, owners outstanding per file, open threads, checks, fast-forward, and the unmet list. The MR screen renders it as a Gates section while the MR is open. Reviews decode counts and show an advisory chip when the gates ignore them; a server without the field decodes to counting reviews and no gates. Live creation flow asserts the section.

Layout: unified · split

gitbay/MRs/MRModels.swift +74 −2
@@ -101,9 +101,12 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
101 let reviewRequests: [String] 101 let reviewRequests: [String]
102 let stackedOn: StackRef? 102 let stackedOn: StackRef?
103 let stacked: [StackRef] 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 enum CodingKeys: String, CodingKey { 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 case bodyFormat = "body_format" 110 case bodyFormat = "body_format"
108 case targetRef = "target_ref" 111 case targetRef = "target_ref"
109 case headSHA = "head_sha" 112 case headSHA = "head_sha"
@@ -145,10 +148,66 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
145 reviewRequests = try container.decodeIfPresent([String].self, forKey: .reviewRequests) ?? [] 148 reviewRequests = try container.decodeIfPresent([String].self, forKey: .reviewRequests) ?? []
146 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn) 149 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn)
147 stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? [] 150 stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? []
151 gates = try container.decodeIfPresent(Gates.self, forKey: .gates)
148 } 152 }
149 153
150 var isOpen: Bool { state == "open" } 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 nonisolated struct Check: Decodable, Sendable, Hashable { 211 nonisolated struct Check: Decodable, Sendable, Hashable {
153 let context: String 212 let context: String
154 let state: String 213 let state: String
@@ -192,13 +251,26 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
192 let verdict: String 251 let verdict: String
193 /// The review predates the current head — it approved older code. 252 /// The review predates the current head — it approved older code.
194 let stale: Bool 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 let createdAt: Date? 258 let createdAt: Date?
196 259
197 enum CodingKeys: String, CodingKey { 260 enum CodingKeys: String, CodingKey {
198 case reviewer, verdict, stale 261 case reviewer, verdict, stale, counts
199 case createdAt = "created_at" 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 var id: String { reviewer } 274 var id: String { reviewer }
203 } 275 }
204} 276}
gitbay/Views/MRs/MRView.swift +54
@@ -53,6 +53,9 @@ struct MRView: View {
53 if let checks = mr.checks, !checks.isEmpty { 53 if let checks = mr.checks, !checks.isEmpty {
54 checksSection(checks, combined: mr.checksCombined) 54 checksSection(checks, combined: mr.checksCombined)
55 } 55 }
56 if let gates = mr.gates, mr.isOpen {
57 gatesSection(gates)
58 }
56 if let reviews = mr.reviews, !reviews.isEmpty { 59 if let reviews = mr.reviews, !reviews.isEmpty {
57 reviewsSection(reviews) 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 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View { 429 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
379 Section("Reviews") { 430 Section("Reviews") {
380 ForEach(reviews) { review in 431 ForEach(reviews) { review in
@@ -389,6 +440,9 @@ struct MRView: View {
389 if review.stale { 440 if review.stale {
390 GBChip("stale", .gbWarn) 441 GBChip("stale", .gbWarn)
391 } 442 }
443 if !review.counts {
444 GBChip("advisory", .secondary)
445 }
392 } 446 }
393 timing(review.createdAt) 447 timing(review.createdAt)
394 } 448 }
gitbayTests/MRViewModelTests.swift +43
@@ -243,6 +243,49 @@ struct MRDetailViewModelTests {
243 #expect(model.revisions.isEmpty) 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 @Test func loadsHeaderDiffAndThreads() async throws { 289 @Test func loadsHeaderDiffAndThreads() async throws {
247 let (model, _) = try await loadedModel() 290 let (model, _) = try await loadedModel()
248 291
gitbayUITests/LiveSmokeUITests.swift +4
@@ -244,6 +244,10 @@ final class LiveSmokeUITests: XCTestCase {
244 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list") 244 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
245 createdRow.tap() 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 // A fresh MR has one revision; the section lists it. 251 // A fresh MR has one revision; the section lists it.
248 let revisions = header("Revisions") 252 let revisions = header("Revisions")
249 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing") 253 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")