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 {
84 await perform(["mr", "milestone"] + ref + [title ?? "none"]) 84 await perform(["mr", "milestone"] + ref + [title ?? "none"])
85 } 85 }
86 86
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
87 func comment(_ text: String, format: String? = nil) async { 109 func comment(_ text: String, format: String? = nil) async {
88 // Long text travels in stdin, but the server only reads it when 110 // Long text travels in stdin, but the server only reads it when
89 // argv says so: --file - is required, not implied. 111 // argv says so: --file - is required, not implied.
gitbay/MRs/MRModels.swift +4 −1
@@ -83,6 +83,8 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
83 let body: String? 83 let body: String?
84 let bodyFormat: String? 84 let bodyFormat: String?
85 let milestone: String? 85 let milestone: String?
86 /// Omitted by the server when there are none.
87 let labels: [String]
86 let createdAt: Date 88 let createdAt: Date
87 /// Set once the merge request is resolved. The actor is absent when 89 /// Set once the merge request is resolved. The actor is absent when
88 /// the account is gone, or the row was imported. 90 /// the account is gone, or the row was imported.
@@ -109,7 +111,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
109 let gates: Gates? 111 let gates: Gates?
110 112
111 enum CodingKeys: String, CodingKey { 113 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
113 case bodyFormat = "body_format" 115 case bodyFormat = "body_format"
114 case targetRef = "target_ref" 116 case targetRef = "target_ref"
115 case headSHA = "head_sha" 117 case headSHA = "head_sha"
@@ -137,6 +139,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
137 body = try container.decodeIfPresent(String.self, forKey: .body) 139 body = try container.decodeIfPresent(String.self, forKey: .body)
138 bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat) 140 bodyFormat = try container.decodeIfPresent(String.self, forKey: .bodyFormat)
139 milestone = try container.decodeIfPresent(String.self, forKey: .milestone) 141 milestone = try container.decodeIfPresent(String.self, forKey: .milestone)
142 labels = try container.decodeIfPresent([String].self, forKey: .labels) ?? []
140 createdAt = try container.decode(Date.self, forKey: .createdAt) 143 createdAt = try container.decode(Date.self, forKey: .createdAt)
141 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt) 144 mergedAt = try container.decodeIfPresent(Date.self, forKey: .mergedAt)
142 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy) 145 mergedBy = try container.decodeIfPresent(String.self, forKey: .mergedBy)
gitbay/Views/MRs/MRView.swift +26
@@ -12,6 +12,7 @@ struct MRView: View {
12 @State private var draftBody = "" 12 @State private var draftBody = ""
13 @State private var draftFormat = "md" 13 @State private var draftFormat = "md"
14 @State private var editingReviewer = "" 14 @State private var editingReviewer = ""
15 @State private var editingLabel = ""
15 @State private var retargeting = false 16 @State private var retargeting = false
16 @State private var pendingRetarget: String? 17 @State private var pendingRetarget: String?
17 @State private var superseding = false 18 @State private var superseding = false
@@ -42,6 +43,7 @@ struct MRView: View {
42 } 43 }
43 44
44 milestoneSection(mr) 45 milestoneSection(mr)
46 labelsSection(mr)
45 stackSection(mr) 47 stackSection(mr)
46 supersededSection(mr) 48 supersededSection(mr)
47 reviewersSection(mr) 49 reviewersSection(mr)
@@ -247,6 +249,30 @@ struct MRView: View {
247 } 249 }
248 } 250 }
249 251
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
250 /// Shown only when this merge request is part of a stack. Each row 276 /// Shown only when this merge request is part of a stack. Each row
251 /// spells out its own direction — "stacked on" vs. "is stacked on 277 /// spells out its own direction — "stacked on" vs. "is stacked on
252 /// this" mean opposite things and look alike if abbreviated. 278 /// 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}