Merge when ready, and cancelling a queued merge !157

merged merged by cmc on 2026-10-14 05:28 UTC · krz/gitbay-ios:merge-when-ready into main

4 files changed, +151 −12

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +8 −1
@@ -124,12 +124,19 @@ final class MRDetailViewModel {
124124 name: name, remove: Reaction.remove(name, given: given)))
125125 }
126126
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 {
128130 var argv = ["mr", "merge"] + ref
129131 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
132 if whenReady { argv.append("--when-ready") }
130133 await perform(argv)
131134 }
132135
136 func cancelQueuedMerge() async {
137 await perform(["mr", "merge"] + ref + ["--cancel"])
138 }
139
133140 /// `--by` records which merge request replaces this one; the server
134141 /// refuses a closed, merged or self reference.
135142 func close(inFavourOf successor: Int64? = nil) async {
gitbay/MRs/MRModels.swift +18 −1
@@ -110,9 +110,11 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
110110 /// The merge gates, computed by the server the way `mr merge` will
111111 /// judge them. Absent from a server older than v1.16.0.
112112 let gates: Gates?
113 /// A merge queued with `mr merge --when-ready`, waiting on the gates.
114 let queued: Queued?
113115
114116 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
116118 case bodyFormat = "body_format"
117119 case targetRef = "target_ref"
118120 case headSHA = "head_sha"
@@ -159,6 +161,21 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
159161 stackedOn = try container.decodeIfPresent(StackRef.self, forKey: .stackedOn)
160162 stacked = try container.decodeIfPresent([StackRef].self, forKey: .stacked) ?? []
161163 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 }
162179 }
163180
164181 var isOpen: Bool { state == "open" }
gitbay/Views/MRs/MRView.swift +52 −10
@@ -6,6 +6,7 @@ struct MRView: View {
66 @State private var commentText = ""
77 @State private var commentFormat = "md"
88 @State private var confirmingMerge = false
9 @State private var confirmingQueue = false
910 @State private var confirmingClose = false
1011 @State private var editing = false
1112 @State private var draftTitle = ""
@@ -84,16 +85,7 @@ struct MRView: View {
8485 .task { await model.load() }
8586 .refreshable { await model.load() }
8687 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
87 if let mr = model.state.value, !mr.stacked.isEmpty {
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) {}
88 strategyButtons(whenReady: false)
9789 } message: {
9890 if let mr = model.state.value, !mr.stacked.isEmpty {
9991 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 {
10193 Text("The server enforces approvals, threads and checks — a refusal will say why.")
10294 }
10395 }
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 }
104101 .confirmationDialog("Close !\(model.number) without merging?", isPresented: $confirmingClose) {
105102 Button("Close", role: .destructive) { Task { await model.close() } }
106103 Button("Close in favour of another…") { superseding = true }
@@ -428,10 +425,29 @@ struct MRView: View {
428425 }
429426 }
430427
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
431444 /// The merge gates as the server will judge them, each row met or
432445 /// not, so a refusal is never the first time a rule is seen.
433446 private func gatesSection(_ gates: MRDetail.Gates) -> some View {
434447 Section {
448 if let queued = model.state.value?.queued {
449 queuedRow(queued)
450 }
435451 if gates.draft {
436452 gateRow(false, "Draft — not asking to merge")
437453 }
@@ -467,6 +483,19 @@ struct MRView: View {
467483 }
468484 }
469485
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
470499 private func gateRow(_ met: Bool, _ text: String) -> some View {
471500 HStack(alignment: .firstTextBaseline) {
472501 Image(systemName: met ? "checkmark.circle.fill" : "circle")
@@ -627,6 +656,19 @@ struct MRView: View {
627656 systemImage: "arrow.triangle.merge")
628657 }
629658 .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 }
630672 Button(role: .destructive) {
631673 confirmingClose = true
632674 } label: {
gitbayTests/MergeWhenReadyTests.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private 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
21private 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
29private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
30
31@MainActor
32struct 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}