Commit 10bbec9546

10bbec954630f419d73861d3a25fd6bd18d74e04

parent: 52faf7192c

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 21:19 UTC

mr: labels on the detail screen

mr label --add/--remove, with chips coloured from the repository's
label set, as issues have.

Layout: unified · split

gitbay/MRs/MRDetailViewModel.swift +22
@@ -84,6 +84,28 @@ final class MRDetailViewModel {
8484 await perform(["mr", "milestone"] + ref + [title ?? "none"])
8585 }
8686
87 /// The repository's labels, for chip colours. Read on first use from
88 /// the Labels section rather than in `load()`, which stays at four
89 /// reads.
90 private(set) var colors = LabelColors()
91 private var labelsLoaded = false
92
93 func loadLabels() async {
94 guard !labelsLoaded else { return }
95 labelsLoaded = true
96 colors = LabelColors(
97 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [])
98 }
99
100 /// A label that does not exist yet is created on the repository.
101 func addLabel(_ label: String) async {
102 await perform(["mr", "label"] + ref + ["--add", label])
103 }
104
105 func removeLabel(_ label: String) async {
106 await perform(["mr", "label"] + ref + ["--remove", label])
107 }
108
87109 func comment(_ text: String, format: String? = nil) async {
88110 // Long text travels in stdin, but the server only reads it when
89111 // argv says so: --file - is required, not implied.
gitbay/MRs/MRModels.swift +4 −1
@@ -83,6 +83,8 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
8383 let body: String?
8484 let bodyFormat: String?
8585 let milestone: String?
86 /// Omitted by the server when there are none.
87 let labels: [String]
8688 let createdAt: Date
8789 /// Set once the merge request is resolved. The actor is absent when
8890 /// the account is gone, or the row was imported.
@@ -109,7 +111,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
109111 let gates: Gates?
110112
111113 enum CodingKeys: String, CodingKey {
112 case number, title, state, author, source, body, milestone, checks, commits, comments, reviews, draft, stacked, gates
114 case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates
113115 case bodyFormat = "body_format"
114116 case targetRef = "target_ref"
115117 case headSHA = "head_sha"
@@ -137,6 +139,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
137139 body = try container.decodeIfPresent(String.self, forKey: .body)
138140 bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat)
139141 milestone = try container.decodeIfPresent(String.self, forKey: .milestone)
142 labels = try container.decodeIfPresent([String].self, forKey: .labels) ?? []
140143 createdAt = try container.decode(Date.self, forKey: .createdAt)
141144 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt)
142145 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy)
gitbay/Views/MRs/MRView.swift +26
@@ -12,6 +12,7 @@ struct MRView: View {
1212 @State private var draftBody = ""
1313 @State private var draftFormat = "md"
1414 @State private var editingReviewer = ""
15 @State private var editingLabel = ""
1516 @State private var retargeting = false
1617 @State private var pendingRetarget: String?
1718 @State private var superseding = false
@@ -42,6 +43,7 @@ struct MRView: View {
4243 }
4344
4445 milestoneSection(mr)
46 labelsSection(mr)
4547 stackSection(mr)
4648 supersededSection(mr)
4749 reviewersSection(mr)
@@ -247,6 +249,30 @@ struct MRView: View {
247249 }
248250 }
249251
252 private func labelsSection(_ mr: MRDetail) -> some View {
253 Section("Labels") {
254 RemovableChips(items: mr.labels, idPrefix: "mr-label",
255 disabled: model.working, color: { model.colors.color($0) },
256 remove: { label in Task { await model.removeLabel(label) } })
257 HStack {
258 TextField("Add label", text: $editingLabel)
259 .autocorrectionDisabled()
260 .textInputAutocapitalization(.never)
261 .accessibilityIdentifier("mr-label-field")
262 Button {
263 let label = editingLabel.trimmingCharacters(in: .whitespaces)
264 editingLabel = ""
265 Task { await model.addLabel(label) }
266 } label: {
267 Image(systemName: "plus.circle.fill")
268 }
269 .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
270 .accessibilityIdentifier("mr-label-add")
271 }
272 }
273 .task { await model.loadLabels() }
274 }
275
250276 /// Shown only when this merge request is part of a stack. Each row
251277 /// spells out its own direction — "stacked on" vs. "is stacked on
252278 /// this" mean opposite things and look alike if abbreviated.
gitbayTests/MRLabelTests.swift added +89
@@ -0,0 +1,89 @@
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 mrShow(labels: String?) -> String {
16 let field = labels.map { #","labels":\#($0)"# } ?? ""
17 return """
18 {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\
19 "source":"feature","target_ref":"main","head_sha":"aa",\
20 "created_at":"2026-09-22T04:40:16.000Z"\(field)},"exit_code":0}
21 """
22}
23
24private let okJSON = #"{"protocol_version":1,"exit_code":0}"#
25
26private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
27 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
28 return try #require(body["argv"] as? [String])
29}
30
31@MainActor
32struct MRLabelTests {
33
34 @Test func mrShowCarriesItsLabels() async throws {
35 let (client, stub) = try makeClient()
36 stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug","ios"]"#), match: "argv=show"))
37 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
38
39 await model.load()
40
41 #expect(try #require(model.state.value).labels == ["bug", "ios"])
42 }
43
44 /// The server omits the key when there are none.
45 @Test func noLabelsKeyMeansNoLabels() async throws {
46 let (client, stub) = try makeClient()
47 stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show"))
48 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
49
50 await model.load()
51
52 #expect(try #require(model.state.value).labels.isEmpty)
53 }
54
55 @Test func addingAndRemovingSendMrLabel() async throws {
56 let (client, stub) = try makeClient()
57 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
58
59 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
60 stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug"]"#), match: "argv=show"))
61 await model.addLabel("bug")
62 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
63 stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show"))
64 await model.removeLabel("bug")
65
66 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
67 #expect(writes == [
68 ["mr", "label", "krz/gitbay", "7", "--add", "bug"],
69 ["mr", "label", "krz/gitbay", "7", "--remove", "bug"],
70 ])
71 #expect(model.actionError == nil)
72 }
73
74 /// Colours come from the repository's label set, read once.
75 @Test func labelColoursAreReadOnce() async throws {
76 let (client, stub) = try makeClient()
77 stub.enqueue(.init(status: 200, json: """
78 {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2}],"exit_code":0}
79 """, match: "argv=label&argv=list"))
80 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
81
82 await model.loadLabels()
83 await model.loadLabels()
84
85 #expect(stub.seen.count == 1)
86 #expect(stub.seen[0].url.query() == "argv=label&argv=list&argv=krz/gitbay")
87 #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a"))
88 }
89}