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 {
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 @@
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}