Commit 4ba5e2812c
Unsigned
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") |