Commit 80d5f2e5cb

80d5f2e5cbc2ac25b6337ca345dc12cee44ada33

parent: 1a3fa0c298

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-06 18:13 UTC

Label list view model: list, set colour, remove

Layout: unified · split

gitbay/Issues/LabelListViewModel.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2import Observation
3
4/// `label list <repo>`, plus the two commands that change the set:
5/// `label set` creates one or recolours it, `label remove` takes it off
6/// the repository and off every issue that carried it.
7@Observable
8@MainActor
9final class LabelListViewModel {
10
11 private(set) var state: LoadState<[IssueLabel]> = .loading
12 private(set) var actionError: String?
13 private(set) var working = false
14
15 private let client: GitbayClient
16 let repoPath: String
17
18 init(client: GitbayClient, repoPath: String) {
19 self.client = client
20 self.repoPath = repoPath
21 }
22
23 func load() async {
24 state = .loading
25 do {
26 let labels = try await client.readList(
27 ["label", "list", repoPath], of: IssueLabel.self
28 )
29 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
30 } catch {
31 state = .from(error)
32 }
33 }
34
35 /// Create a label or set its colour. A nil colour omits `--color`
36 /// entirely, which the server reads as "make sure it exists" and
37 /// leaves any colour it already had; an empty string clears it.
38 func setLabel(_ name: String, color: String?) async {
39 var argv = ["label", "set", repoPath, name]
40 if let color { argv.append(contentsOf: ["--color", color]) }
41 await perform(argv)
42 }
43
44 func remove(_ name: String) async {
45 await perform(["label", "remove", repoPath, name])
46 }
47
48 private func perform(_ argv: [String]) async {
49 working = true
50 actionError = nil
51 defer { working = false }
52 do {
53 try await client.run(argv)
54 await load()
55 } catch let error as GitbayError {
56 actionError = error.userFacingMessage
57 } catch {
58 actionError = GitbayError.transport(error).userFacingMessage
59 }
60 }
61}
gitbayTests/LabelTests.swift +102
@@ -183,3 +183,105 @@ struct IssueDetailColorsTests {
183183 #expect(model.colors.hex("bug") == "#cf222e")
184184 }
185185}
186
187@MainActor
188struct LabelListViewModelTests {
189
190 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
191 let (client, stub) = try makeClient()
192 stub.enqueue(.init(status: 200, json: labelListJSON))
193 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
194 await model.load()
195 return (model, stub)
196 }
197
198 @Test func listsLabelsWithTheirUseCounts() async throws {
199 let (model, stub) = try await loaded()
200
201 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
202 #expect(model.state.value?.map(\.issues) == [18, 11])
203 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
204 }
205
206 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
207 let (client, stub) = try makeClient()
208 stub.enqueue(.init(status: 200, json: """
209 {"protocol_version":1,"exit_code":0}
210 """))
211 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
212 await model.load()
213
214 guard case .empty = model.state else {
215 Testing.Issue.record("expected an empty state, got \(model.state)")
216 return
217 }
218 }
219
220 @Test func settingAColourSendsTheColourFlag() async throws {
221 let (model, stub) = try await loaded()
222 stub.enqueue(.init(status: 200, json: """
223 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
224 """))
225 stub.enqueue(.init(status: 200, json: labelListJSON))
226
227 await model.setLabel("bug", color: "cf222e")
228
229 let write = try #require(stub.seen.first { $0.method == "POST" })
230 #expect(try argvOf(write)
231 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
232 }
233
234 /// A label created without a colour must not send `--color`, which
235 /// would be "clear it" rather than "leave whatever it has".
236 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
237 let (model, stub) = try await loaded()
238 stub.enqueue(.init(status: 200, json: """
239 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
240 """))
241 stub.enqueue(.init(status: 200, json: labelListJSON))
242
243 await model.setLabel("new", color: nil)
244
245 let write = try #require(stub.seen.first { $0.method == "POST" })
246 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
247 }
248
249 /// An empty string is not nil: it clears the colour.
250 @Test func clearingSendsAnEmptyColour() async throws {
251 let (model, stub) = try await loaded()
252 stub.enqueue(.init(status: 200, json: """
253 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
254 """))
255 stub.enqueue(.init(status: 200, json: labelListJSON))
256
257 await model.setLabel("bug", color: "")
258
259 let write = try #require(stub.seen.first { $0.method == "POST" })
260 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
261 }
262
263 @Test func removingSendsTheRemoveCommand() async throws {
264 let (model, stub) = try await loaded()
265 stub.enqueue(.init(status: 200, json: """
266 {"protocol_version":1,"exit_code":0}
267 """))
268 stub.enqueue(.init(status: 200, json: labelListJSON))
269
270 await model.remove("bug")
271
272 let write = try #require(stub.seen.first { $0.method == "POST" })
273 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
274 }
275
276 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
277 let (model, stub) = try await loaded()
278 stub.enqueue(.init(status: 403, json: """
279 {"protocol_version":1,"error":"write access required","exit_code":4}
280 """))
281
282 await model.remove("bug")
283
284 #expect(model.actionError?.isEmpty == false)
285 #expect(model.working == false)
286 }
287}