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 {
101101 let reviewRequests: [String]
102102 let stackedOn: StackRef?
103103 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?
104107
105108 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
107110 case bodyFormat = "body_format"
108111 case targetRef = "target_ref"
109112 case headSHA = "head_sha"
@@ -145,10 +148,66 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
145148 reviewRequests = try container.decodeIfPresent([String].self, forKey: .reviewRequests) ?? []
146149 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn)
147150 stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? []
151 gates = try container.decodeIfPresent(Gates.self, forKey: .gates)
148152 }
149153
150154 var isOpen: Bool { state == "open" }
151155
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
152211 nonisolated struct Check: Decodable, Sendable, Hashable {
153212 let context: String
154213 let state: String
@@ -192,13 +251,26 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
192251 let verdict: String
193252 /// The review predates the current head — it approved older code.
194253 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
195258 let createdAt: Date?
196259
197260 enum CodingKeys: String, CodingKey {
198 case reviewer, verdict, stale
261 case reviewer, verdict, stale, counts
199262 case createdAt = "created_at"
200263 }
201264
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
202274 var id: String { reviewer }
203275 }
204276}
gitbay/Views/MRs/MRView.swift +54
@@ -53,6 +53,9 @@ struct MRView: View {
5353 if let checks = mr.checks, !checks.isEmpty {
5454 checksSection(checks, combined: mr.checksCombined)
5555 }
56 if let gates = mr.gates, mr.isOpen {
57 gatesSection(gates)
58 }
5659 if let reviews = mr.reviews, !reviews.isEmpty {
5760 reviewsSection(reviews)
5861 }
@@ -375,6 +378,54 @@ struct MRView: View {
375378 }
376379 }
377380
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
378429 private func reviewsSection(_ reviews: [MRDetail.Review]) -> some View {
379430 Section("Reviews") {
380431 ForEach(reviews) { review in
@@ -389,6 +440,9 @@ struct MRView: View {
389440 if review.stale {
390441 GBChip("stale", .gbWarn)
391442 }
443 if !review.counts {
444 GBChip("advisory", .secondary)
445 }
392446 }
393447 timing(review.createdAt)
394448 }
gitbayTests/MRViewModelTests.swift +43
@@ -243,6 +243,49 @@ struct MRDetailViewModelTests {
243243 #expect(model.revisions.isEmpty)
244244 }
245245
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
246289 @Test func loadsHeaderDiffAndThreads() async throws {
247290 let (model, _) = try await loadedModel()
248291
gitbayUITests/LiveSmokeUITests.swift +4
@@ -244,6 +244,10 @@ final class LiveSmokeUITests: XCTestCase {
244244 XCTAssertTrue(createdRow.waitForExistence(timeout: 15), "created MR not in the list")
245245 createdRow.tap()
246246
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
247251 // A fresh MR has one revision; the section lists it.
248252 let revisions = header("Revisions")
249253 XCTAssertTrue(scrollTo(revisions, swipes: 8), "revisions section missing")