Commit a61992cc96

a61992cc969fc503b5b107ce14b459931721550d

parent: 0084a879cd

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 20:36 UTC

Draft, retarget and review requests on a merge request

Layout: unified · split

gitbay/MRs/MRCreateViewModel.swift +2
@@ -13,6 +13,7 @@ final class MRCreateViewModel {
13 private(set) var working = false 13 private(set) var working = false
14 private(set) var errorMessage: String? 14 private(set) var errorMessage: String?
15 private(set) var defaultBranch: String? 15 private(set) var defaultBranch: String?
16 var draft = false
16 17
17 private let client: GitbayClient 18 private let client: GitbayClient
18 let repoPath: String 19 let repoPath: String
@@ -45,6 +46,7 @@ final class MRCreateViewModel {
45 argv.append(contentsOf: ["--file", "-"]) 46 argv.append(contentsOf: ["--file", "-"])
46 stdin = body 47 stdin = body
47 } 48 }
49 if draft { argv.append("--draft") }
48 let created = try await client.run(argv, stdin: stdin, as: Created.self) 50 let created = try await client.run(argv, stdin: stdin, as: Created.self)
49 return created?.number 51 return created?.number
50 } catch let error as GitbayError { 52 } catch let error as GitbayError {
gitbay/MRs/MRDetailViewModel.swift +24
@@ -108,6 +108,30 @@ final class MRDetailViewModel {
108 ) 108 )
109 } 109 }
110 110
111 func setDraft(_ draft: Bool) async {
112 await perform(["mr", draft ? "draft" : "ready"] + ref)
113 }
114
115 func retarget(to branch: String) async {
116 await perform(["mr", "retarget"] + ref + [branch])
117 }
118
119 func requestReview(from user: String) async {
120 await perform(["mr", "review", "request"] + ref + ["--add", user])
121 }
122
123 func removeReviewRequest(_ user: String) async {
124 await perform(["mr", "review", "request"] + ref + ["--remove", user])
125 }
126
127 /// Branches for the retarget picker; fetched on demand from `repo refs`.
128 private(set) var branches: [String] = []
129
130 func loadBranches() async {
131 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
132 branches = refs?.branches.map(\.name) ?? []
133 }
134
111 private func perform(_ argv: [String], stdin: String? = nil) async { 135 private func perform(_ argv: [String], stdin: String? = nil) async {
112 working = true 136 working = true
113 actionError = nil 137 actionError = nil
gitbayTests/MRLifecycleTests.swift +142
@@ -88,3 +88,145 @@ struct MRLifecycleDecodingTests {
88 #expect(row.stackedOn == nil) 88 #expect(row.stackedOn == nil)
89 } 89 }
90} 90}
91
92@MainActor
93struct MRLifecycleActionTests {
94
95 private func loaded() async throws -> (MRDetailViewModel, StubProtocol.Box) {
96 let box = StubProtocol.box()
97 let client = GitbayClient(
98 instance: try GitbayInstance(url: "https://gitbay.org"),
99 token: "test-token",
100 session: box.session()
101 )
102 box.enqueue(.init(status: 200, json: """
103 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
104 """))
105 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
106 await model.load()
107 return (model, box)
108 }
109
110 private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
111 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
112 return try #require(body["argv"] as? [String])
113 }
114
115 private func ok(_ box: StubProtocol.Box) {
116 box.enqueue(.init(status: 200, json: """
117 {"protocol_version":1,"exit_code":0}
118 """))
119 box.enqueue(.init(status: 200, json: """
120 {"protocol_version":1,"data":\(plainDetail),"exit_code":0}
121 """))
122 }
123
124 @Test func markingDraftAndReadyAreDifferentCommands() async throws {
125 let (model, box) = try await loaded()
126 ok(box)
127 await model.setDraft(true)
128 var write = try #require(box.seen.first { $0.method == "POST" })
129 #expect(try argvOf(write) == ["mr", "draft", "krz/gitbay", "7"])
130
131 let (model2, box2) = try await loaded()
132 ok(box2)
133 await model2.setDraft(false)
134 write = try #require(box2.seen.first { $0.method == "POST" })
135 #expect(try argvOf(write) == ["mr", "ready", "krz/gitbay", "7"])
136 }
137
138 @Test func retargetPassesTheBranchPositionally() async throws {
139 let (model, box) = try await loaded()
140 ok(box)
141 await model.retarget(to: "release")
142 let write = try #require(box.seen.first { $0.method == "POST" })
143 #expect(try argvOf(write) == ["mr", "retarget", "krz/gitbay", "7", "release"])
144 }
145
146 /// `mr review request` is a THREE-word path. Two words posts a
147 /// review instead — silently, with a 200.
148 @Test func requestingAReviewUsesTheThreeWordPath() async throws {
149 let (model, box) = try await loaded()
150 ok(box)
151 await model.requestReview(from: "rae")
152 let write = try #require(box.seen.first { $0.method == "POST" })
153 #expect(try argvOf(write)
154 == ["mr", "review", "request", "krz/gitbay", "7", "--add", "rae"])
155 }
156
157 @Test func removingAReviewRequestUsesRemoveNotAdd() async throws {
158 let (model, box) = try await loaded()
159 ok(box)
160 await model.removeReviewRequest("rae")
161 let write = try #require(box.seen.first { $0.method == "POST" })
162 #expect(try argvOf(write)
163 == ["mr", "review", "request", "krz/gitbay", "7", "--remove", "rae"])
164 }
165
166 @Test func branchesComeFromRepoRefs() async throws {
167 let (model, box) = try await loaded()
168 box.enqueue(.init(status: 200, json: """
169 {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"a"},\
170 {"name":"release","sha":"b"}],"tags":[]},"exit_code":0}
171 """))
172 await model.loadBranches()
173 #expect(model.branches == ["main", "release"])
174 }
175
176 @Test func aRefusalSurfacesAndDoesNotReload() async throws {
177 let (model, box) = try await loaded()
178 box.enqueue(.init(status: 200, json: """
179 {"protocol_version":1,"error":"cannot retarget a merged merge request",\
180 "exit_code":1}
181 """))
182 await model.retarget(to: "release")
183 #expect(model.actionError?.isEmpty == false)
184 #expect(model.working == false)
185 }
186}
187
188@MainActor
189struct MRCreateDraftTests {
190
191 @Test func creatingAsADraftAppendsTheFlag() async throws {
192 let box = StubProtocol.box()
193 let client = GitbayClient(
194 instance: try GitbayInstance(url: "https://gitbay.org"),
195 token: "test-token",
196 session: box.session()
197 )
198 box.enqueue(.init(status: 200, json: """
199 {"protocol_version":1,"data":{"number":7},"exit_code":0}
200 """))
201 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
202 model.draft = true
203 _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
204
205 let write = try #require(box.seen.first { $0.method == "POST" })
206 let body = try #require(
207 try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
208 )
209 let argv = try #require(body["argv"] as? [String])
210 #expect(argv.contains("--draft"))
211 }
212
213 @Test func creatingWithoutDraftOmitsTheFlag() async throws {
214 let box = StubProtocol.box()
215 let client = GitbayClient(
216 instance: try GitbayInstance(url: "https://gitbay.org"),
217 token: "test-token",
218 session: box.session()
219 )
220 box.enqueue(.init(status: 200, json: """
221 {"protocol_version":1,"data":{"number":7},"exit_code":0}
222 """))
223 let model = MRCreateViewModel(client: client, repoPath: "krz/gitbay")
224 _ = await model.create(source: "feat", target: "main", title: "a change", body: "")
225
226 let write = try #require(box.seen.first { $0.method == "POST" })
227 let body = try #require(
228 try JSONSerialization.jsonObject(with: write.body) as? [String: Any]
229 )
230 #expect((body["argv"] as? [String])?.contains("--draft") == false)
231 }
232}