Merge when ready, and cancelling a queued merge !157
4 files changed, +151 −12
Layout: unified · split
gitbay/MRs/MRDetailViewModel.swift +8 −1
| @@ -124,12 +124,19 @@ final class MRDetailViewModel { | |||
| 124 | name: name, remove: Reaction.remove(name, given: given))) | 124 | name: name, remove: Reaction.remove(name, given: given))) |
| 125 | } | 125 | } |
| 126 | 126 | ||
| 127 | func merge(strategy: String? = nil) async { | 127 | /// `whenReady` queues the merge until the gates pass; the server |
| 128 | /// merges at once if they already do. | ||
| 129 | func merge(strategy: String? = nil, whenReady: Bool = false) async { | ||
| 128 | var argv = ["mr", "merge"] + ref | 130 | var argv = ["mr", "merge"] + ref |
| 129 | if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } | 131 | if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } |
| 132 | if whenReady { argv.append("--when-ready") } | ||
| 130 | await perform(argv) | 133 | await perform(argv) |
| 131 | } | 134 | } |
| 132 | 135 | ||
| 136 | func cancelQueuedMerge() async { | ||
| 137 | await perform(["mr", "merge"] + ref + ["--cancel"]) | ||
| 138 | } | ||
| 139 | |||
| 133 | /// `--by` records which merge request replaces this one; the server | 140 | /// `--by` records which merge request replaces this one; the server |
| 134 | /// refuses a closed, merged or self reference. | 141 | /// refuses a closed, merged or self reference. |
| 135 | func close(inFavourOf successor: Int64? = nil) async { | 142 | func close(inFavourOf successor: Int64? = nil) async { |
gitbay/MRs/MRModels.swift +18 −1
| @@ -110,9 +110,11 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 110 | /// The merge gates, computed by the server the way `mr merge` will | 110 | /// The merge gates, computed by the server the way `mr merge` will |
| 111 | /// judge them. Absent from a server older than v1.16.0. | 111 | /// judge them. Absent from a server older than v1.16.0. |
| 112 | let gates: Gates? | 112 | let gates: Gates? |
| 113 | /// A merge queued with `mr merge --when-ready`, waiting on the gates. | ||
| 114 | let queued: Queued? | ||
| 113 | 115 | ||
| 114 | enum CodingKeys: String, CodingKey { | 116 | enum CodingKeys: String, CodingKey { |
| 115 | case number, title, state, author, source, body, milestone, labels, checks, commits, reactions, comments, reviews, draft, stacked, gates | 117 | case number, title, state, author, source, body, milestone, labels, checks, commits, reactions, comments, reviews, draft, stacked, gates, queued |
| 116 | case bodyFormat = "body_format" | 118 | case bodyFormat = "body_format" |
| 117 | case targetRef = "target_ref" | 119 | case targetRef = "target_ref" |
| 118 | case headSHA = "head_sha" | 120 | case headSHA = "head_sha" |
| @@ -159,6 +161,21 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { | |||
| 159 | stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn) | 161 | stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn) |
| 160 | stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? [] | 162 | stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? [] |
| 161 | gates = try container.decodeIfPresent(Gates.self, forKey: .gates) | 163 | gates = try container.decodeIfPresent(Gates.self, forKey: .gates) |
| 164 | queued = try container.decodeIfPresent(Queued.self, forKey: .queued) | ||
| 165 | } | ||
| 166 | |||
| 167 | /// Who queued the merge, with which strategy, and why it has not | ||
| 168 | /// merged yet. | ||
| 169 | nonisolated struct Queued: Decodable, Sendable, Hashable { | ||
| 170 | let by: String | ||
| 171 | let strategy: String? | ||
| 172 | let reason: String? | ||
| 173 | let queuedAt: Date | ||
| 174 | |||
| 175 | enum CodingKeys: String, CodingKey { | ||
| 176 | case by, strategy, reason | ||
| 177 | case queuedAt = "queued_at" | ||
| 178 | } | ||
| 162 | } | 179 | } |
| 163 | 180 | ||
| 164 | var isOpen: Bool { state == "open" } | 181 | var isOpen: Bool { state == "open" } |
gitbay/Views/MRs/MRView.swift +52 −10
| @@ -6,6 +6,7 @@ struct MRView: View { | |||
| 6 | @State private var commentText = "" | 6 | @State private var commentText = "" |
| 7 | @State private var commentFormat = "md" | 7 | @State private var commentFormat = "md" |
| 8 | @State private var confirmingMerge = false | 8 | @State private var confirmingMerge = false |
| 9 | @State private var confirmingQueue = false | ||
| 9 | @State private var confirmingClose = false | 10 | @State private var confirmingClose = false |
| 10 | @State private var editing = false | 11 | @State private var editing = false |
| 11 | @State private var draftTitle = "" | 12 | @State private var draftTitle = "" |
| @@ -84,16 +85,7 @@ struct MRView: View { | |||
| 84 | .task { await model.load() } | 85 | .task { await model.load() } |
| 85 | .refreshable { await model.load() } | 86 | .refreshable { await model.load() } |
| 86 | .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { | 87 | .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { |
| 87 | if let mr = model.state.value, !mr.stacked.isEmpty { | 88 | strategyButtons(whenReady: false) |
| 88 | Button("Merge") { Task { await model.merge() } } | ||
| 89 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } | ||
| 90 | } else { | ||
| 91 | Button("Merge") { Task { await model.merge() } } | ||
| 92 | Button("Squash") { Task { await model.merge(strategy: "squash") } } | ||
| 93 | Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } | ||
| 94 | Button("Rebase") { Task { await model.merge(strategy: "rebase") } } | ||
| 95 | } | ||
| 96 | Button("Cancel", role: .cancel) {} | ||
| 97 | } message: { | 89 | } message: { |
| 98 | if let mr = model.state.value, !mr.stacked.isEmpty { | 90 | if let mr = model.state.value, !mr.stacked.isEmpty { |
| 99 | Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.") | 91 | Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.") |
| @@ -101,6 +93,11 @@ struct MRView: View { | |||
| 101 | Text("The server enforces approvals, threads and checks — a refusal will say why.") | 93 | Text("The server enforces approvals, threads and checks — a refusal will say why.") |
| 102 | } | 94 | } |
| 103 | } | 95 | } |
| 96 | .confirmationDialog("Merge !\(model.number) when ready?", isPresented: $confirmingQueue) { | ||
| 97 | strategyButtons(whenReady: true) | ||
| 98 | } message: { | ||
| 99 | Text("The server merges it once every gate passes, and at once if they already do.") | ||
| 100 | } | ||
| 104 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { | 101 | .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) { |
| 105 | Button("Close", role: .destructive) { Task { await model.close() } } | 102 | Button("Close", role: .destructive) { Task { await model.close() } } |
| 106 | Button("Close in favour of another…") { superseding = true } | 103 | Button("Close in favour of another…") { superseding = true } |
| @@ -428,10 +425,29 @@ struct MRView: View { | |||
| 428 | } | 425 | } |
| 429 | } | 426 | } |
| 430 | 427 | ||
| 428 | /// The strategies the dialog offers. A stack rules out the two that | ||
| 429 | /// rewrite the commits it builds on. | ||
| 430 | @ViewBuilder | ||
| 431 | private func strategyButtons(whenReady: Bool) -> some View { | ||
| 432 | let stacked = !(model.state.value?.stacked.isEmpty ?? true) | ||
| 433 | Button("Merge") { Task { await model.merge(whenReady: whenReady) } } | ||
| 434 | if !stacked { | ||
| 435 | Button("Squash") { Task { await model.merge(strategy: "squash", whenReady: whenReady) } } | ||
| 436 | } | ||
| 437 | Button("Fast-forward") { Task { await model.merge(strategy: "ff", whenReady: whenReady) } } | ||
| 438 | if !stacked { | ||
| 439 | Button("Rebase") { Task { await model.merge(strategy: "rebase", whenReady: whenReady) } } | ||
| 440 | } | ||
| 441 | Button("Cancel", role: .cancel) {} | ||
| 442 | } | ||
| 443 | |||
| 431 | /// The merge gates as the server will judge them, each row met or | 444 | /// The merge gates as the server will judge them, each row met or |
| 432 | /// not, so a refusal is never the first time a rule is seen. | 445 | /// not, so a refusal is never the first time a rule is seen. |
| 433 | private func gatesSection(_ gates: MRDetail.Gates) -> some View { | 446 | private func gatesSection(_ gates: MRDetail.Gates) -> some View { |
| 434 | Section { | 447 | Section { |
| 448 | if let queued = model.state.value?.queued { | ||
| 449 | queuedRow(queued) | ||
| 450 | } | ||
| 435 | if gates.draft { | 451 | if gates.draft { |
| 436 | gateRow(false, "Draft — not asking to merge") | 452 | gateRow(false, "Draft — not asking to merge") |
| 437 | } | 453 | } |
| @@ -467,6 +483,19 @@ struct MRView: View { | |||
| 467 | } | 483 | } |
| 468 | } | 484 | } |
| 469 | 485 | ||
| 486 | private func queuedRow(_ queued: MRDetail.Queued) -> some View { | ||
| 487 | VStack(alignment: .leading, spacing: 2) { | ||
| 488 | Label("Merges when ready" + (queued.strategy.map { " (\($0))" } ?? ""), systemImage: "clock.arrow.circlepath") | ||
| 489 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 490 | Text("Queued by \(queued.by) \(queued.queuedAt.formatted(.relative(presentation: .named)))" | ||
| 491 | + (queued.reason.map { " — \($0)" } ?? "")) | ||
| 492 | .font(.gbSans(.caption)) | ||
| 493 | .foregroundStyle(.secondary) | ||
| 494 | } | ||
| 495 | .accessibilityElement(children: .combine) | ||
| 496 | .accessibilityIdentifier("mr-queued") | ||
| 497 | } | ||
| 498 | |||
| 470 | private func gateRow(_ met: Bool, _ text: String) -> some View { | 499 | private func gateRow(_ met: Bool, _ text: String) -> some View { |
| 471 | HStack(alignment: .firstTextBaseline) { | 500 | HStack(alignment: .firstTextBaseline) { |
| 472 | Image(systemName: met ? "checkmark.circle.fill" : "circle") | 501 | Image(systemName: met ? "checkmark.circle.fill" : "circle") |
| @@ -627,6 +656,19 @@ struct MRView: View { | |||
| 627 | systemImage: "arrow.triangle.merge") | 656 | systemImage: "arrow.triangle.merge") |
| 628 | } | 657 | } |
| 629 | .disabled(mr.draft) | 658 | .disabled(mr.draft) |
| 659 | if mr.queued != nil { | ||
| 660 | Button { | ||
| 661 | Task { await model.cancelQueuedMerge() } | ||
| 662 | } label: { | ||
| 663 | Label("Cancel queued merge", systemImage: "clock.badge.xmark") | ||
| 664 | } | ||
| 665 | } else { | ||
| 666 | Button { | ||
| 667 | confirmingQueue = true | ||
| 668 | } label: { | ||
| 669 | Label("Merge when ready", systemImage: "clock.arrow.circlepath") | ||
| 670 | } | ||
| 671 | } | ||
| 630 | Button(role: .destructive) { | 672 | Button(role: .destructive) { |
| 631 | confirmingClose = true | 673 | confirmingClose = true |
| 632 | } label: { | 674 | } label: { |
gitbayTests/MergeWhenReadyTests.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func postedArgv(_ stub: StubProtocol.Box) throws -> [String] { | ||
| 16 | let seen = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 18 | return try #require(body["argv"] as? [String]) | ||
| 19 | } | ||
| 20 | |||
| 21 | private func mrShow(queued: String = "") -> String { | ||
| 22 | """ | ||
| 23 | {"protocol_version":1,"data":{"number":5,"title":"t","state":"open","author":"krz",\ | ||
| 24 | "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z"\ | ||
| 25 | \(queued)},"exit_code":0} | ||
| 26 | """ | ||
| 27 | } | ||
| 28 | |||
| 29 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 30 | |||
| 31 | @MainActor | ||
| 32 | struct MergeWhenReadyTests { | ||
| 33 | |||
| 34 | private func loaded(_ json: String) async throws -> (MRDetailViewModel, StubProtocol.Box) { | ||
| 35 | let (client, stub) = try makeClient() | ||
| 36 | stub.enqueue(.init(status: 200, json: json, match: "argv=show")) | ||
| 37 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 5) | ||
| 38 | await model.load() | ||
| 39 | return (model, stub) | ||
| 40 | } | ||
| 41 | |||
| 42 | @Test func aQueuedMergeDecodesWhoQueuedItAndWhy() async throws { | ||
| 43 | let (model, _) = try await loaded(mrShow(queued: #","queued":{"by":"cmc","strategy":"ff","reason":"waiting on 1 approval","queued_at":"2026-08-21T10:00:00.000Z"}"#)) | ||
| 44 | |||
| 45 | let queued = try #require(model.state.value?.queued) | ||
| 46 | #expect(queued.by == "cmc") | ||
| 47 | #expect(queued.strategy == "ff") | ||
| 48 | #expect(queued.reason == "waiting on 1 approval") | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func noQueueDecodesAsNil() async throws { | ||
| 52 | let (model, _) = try await loaded(mrShow()) | ||
| 53 | #expect(model.state.value?.queued == nil) | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test func mergeWhenReadyAddsTheFlagAndKeepsTheStrategy() async throws { | ||
| 57 | let (model, stub) = try await loaded(mrShow()) | ||
| 58 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 59 | |||
| 60 | await model.merge(strategy: "ff", whenReady: true) | ||
| 61 | |||
| 62 | #expect(try postedArgv(stub) == ["mr", "merge", "krz/gitbay", "5", "--strategy", "ff", "--when-ready"]) | ||
| 63 | } | ||
| 64 | |||
| 65 | @Test func cancellingTheQueueSendsCancelAlone() async throws { | ||
| 66 | let (model, stub) = try await loaded(mrShow(queued: #","queued":{"by":"cmc","queued_at":"2026-08-21T10:00:00.000Z"}"#)) | ||
| 67 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 68 | |||
| 69 | await model.cancelQueuedMerge() | ||
| 70 | |||
| 71 | #expect(try postedArgv(stub) == ["mr", "merge", "krz/gitbay", "5", "--cancel"]) | ||
| 72 | } | ||
| 73 | } | ||