Commit 60cc6810d1

60cc6810d163822ffc1d5d22221cb58e828f7890

parent: c694ac101b

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 21:44 UTC

Fix merge dialog stack options, branch error clearing, draft toggle, and reload assertions

- Merge dialog offers Merge and Fast-forward on a stacked branch, not just Fast-forward
- loadBranches() clears branchesError before fetching
- MRCreateSheet exposes the draft toggle, making --draft reachable
- MR lifecycle action tests assert the reload GETs happen, and that a refusal skips them

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +1
@@ -139,6 +139,7 @@ final class MRDetailViewModel {
139 /// secondary action, not the primary content `load()` refreshes. 139 /// secondary action, not the primary content `load()` refreshes.
140 func loadBranches() async { 140 func loadBranches() async {
141 guard branches == nil else { return } 141 guard branches == nil else { return }
142 branchesError = nil
142 do { 143 do {
143 let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self) 144 let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
144 branches = refs.branches.map(\.name) 145 branches = refs.branches.map(\.name)
gitbay/Views/MRs/MRListView.swift +6
@@ -116,6 +116,12 @@ private struct MRCreateSheet: View {
116 .autocorrectionDisabled() 116 .autocorrectionDisabled()
117 .accessibilityIdentifier("mr-body") 117 .accessibilityIdentifier("mr-body")
118 } 118 }
119 Section {
120 Toggle("Draft", isOn: Bindable(model).draft)
121 .accessibilityIdentifier("mr-draft")
122 } footer: {
123 Text("A draft merge request is open but not asking for review: it won't merge and won't show up in a review queue.")
124 }
119 if let error = model.errorMessage { 125 if let error = model.errorMessage {
120 Section { 126 Section {
121 GBNotice(error) 127 GBNotice(error)
gitbay/Views/MRs/MRView.swift +2 −1
@@ -65,6 +65,7 @@ struct MRView: View {
65 .refreshable { await model.load() } 65 .refreshable { await model.load() }
66 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) { 66 .confirmationDialog("Merge !\(model.number)?", isPresented: $confirmingMerge) {
67 if let mr = model.state.value, !mr.stacked.isEmpty { 67 if let mr = model.state.value, !mr.stacked.isEmpty {
68 Button("Merge") { Task { await model.merge() } }
68 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } } 69 Button("Fast-forward") { Task { await model.merge(strategy: "ff") } }
69 } else { 70 } else {
70 Button("Merge") { Task { await model.merge() } } 71 Button("Merge") { Task { await model.merge() } }
@@ -75,7 +76,7 @@ struct MRView: View {
75 Button("Cancel", role: .cancel) {} 76 Button("Cancel", role: .cancel) {}
76 } message: { 77 } message: {
77 if let mr = model.state.value, !mr.stacked.isEmpty { 78 if let mr = model.state.value, !mr.stacked.isEmpty {
78 Text("Merge requests are stacked on this one. Only a fast-forward merge is offered — squash and rebase would rewrite the commits the stack builds on.") 79 Text("Merge requests are stacked on this one. Squash and rebase are unavailable — they would rewrite the commits the stack builds on.")
79 } else { 80 } else {
80 Text("The server enforces approvals, threads and checks — a refusal will say why.") 81 Text("The server enforces approvals, threads and checks — a refusal will say why.")
81 } 82 }
gitbayTests/MRLifecycleTests.swift +15
@@ -121,18 +121,27 @@ struct MRLifecycleActionTests {
121 """)) 121 """))
122 } 122 }
123 123
124 /// `load()` issues three GETs: `mr show`, `mr diff`, `mr threads`.
125 /// `loaded()` runs it once (3 GETs, the last two unstubbed and failed),
126 /// so a reload after a write brings the total to 6.
127 private func gets(_ box: StubProtocol.Box) -> Int {
128 box.seen.count { $0.method == "GET" }
129 }
130
124 @Test func markingDraftAndReadyAreDifferentCommands() async throws { 131 @Test func markingDraftAndReadyAreDifferentCommands() async throws {
125 let (model, box) = try await loaded() 132 let (model, box) = try await loaded()
126 ok(box) 133 ok(box)
127 await model.setDraft(true) 134 await model.setDraft(true)
128 var write = try #require(box.seen.first { $0.method == "POST" }) 135 var write = try #require(box.seen.first { $0.method == "POST" })
129 #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"]) 136 #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
137 #expect(gets(box) == 6)
130 138
131 let (model2, box2) = try await loaded() 139 let (model2, box2) = try await loaded()
132 ok(box2) 140 ok(box2)
133 await model2.setDraft(false) 141 await model2.setDraft(false)
134 write = try #require(box2.seen.first { $0.method == "POST" }) 142 write = try #require(box2.seen.first { $0.method == "POST" })
135 #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"]) 143 #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
144 #expect(gets(box2) == 6)
136 } 145 }
137 146
138 @Test func retargetPassesTheBranchPositionally() async throws { 147 @Test func retargetPassesTheBranchPositionally() async throws {
@@ -141,6 +150,7 @@ struct MRLifecycleActionTests {
141 await model.retarget(to: "release") 150 await model.retarget(to: "release")
142 let write = try #require(box.seen.first { $0.method == "POST" }) 151 let write = try #require(box.seen.first { $0.method == "POST" })
143 #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"]) 152 #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
153 #expect(gets(box) == 6)
144 } 154 }
145 155
146 /// `mr review request` is a THREE-word path. Two words posts a 156 /// `mr review request` is a THREE-word path. Two words posts a
@@ -152,6 +162,7 @@ struct MRLifecycleActionTests {
152 let write = try #require(box.seen.first { $0.method == "POST" }) 162 let write = try #require(box.seen.first { $0.method == "POST" })
153 #expect(try argvOf(write) 163 #expect(try argvOf(write)
154 == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"]) 164 == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
165 #expect(gets(box) == 6)
155 } 166 }
156 167
157 @Test func removingAReviewRequestUsesRemoveNotAdd() async throws { 168 @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
@@ -161,6 +172,7 @@ struct MRLifecycleActionTests {
161 let write = try #require(box.seen.first { $0.method == "POST" }) 172 let write = try #require(box.seen.first { $0.method == "POST" })
162 #expect(try argvOf(write) 173 #expect(try argvOf(write)
163 == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"]) 174 == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
175 #expect(gets(box) == 6)
164 } 176 }
165 177
166 @Test func branchesComeFromRepoRefs() async throws { 178 @Test func branchesComeFromRepoRefs() async throws {
@@ -182,6 +194,9 @@ struct MRLifecycleActionTests {
182 await model.retarget(to: "release") 194 await model.retarget(to: "release")
183 #expect(model.actionError?.isEmpty == false) 195 #expect(model.actionError?.isEmpty == false)
184 #expect(model.working == false) 196 #expect(model.working == false)
197 // A refusal throws before `perform` reaches `load()`: still the
198 // 3 GETs from `loaded()`, none from a reload.
199 #expect(gets(box) == 3)
185 } 200 }
186} 201}
187 202