Commit 1a3fa0c298

1a3fa0c298b9a4fd44c2efeae063fc80a364900a

parent: 60019a10fc

Verified · cmc

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

Paint label chips the colour the web paints them

Layout: unified · split

gitbay/Issues/IssueDetailViewModel.swift +8
@@ -12,6 +12,8 @@ final class IssueDetailViewModel {
1212 private(set) var working = false
1313 /// Open milestones for the picker; fetched on first use.
1414 private(set) var availableMilestones: [Milestone]?
15 private(set) var labels: [IssueLabel] = []
16 private(set) var colors = LabelColors()
1517
1618 private let client: GitbayClient
1719 let repoPath: String
@@ -31,6 +33,12 @@ final class IssueDetailViewModel {
3133 } catch {
3234 state = .from(error)
3335 }
36 labels = await labelList()
37 colors = LabelColors(labels)
38 }
39
40 private func labelList() async -> [IssueLabel] {
41 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
3442 }
3543
3644 /// Both fields are always sent: the title as given, the body over
gitbay/Issues/IssueListViewModel.swift +14
@@ -13,6 +13,7 @@ final class IssueListViewModel {
1313
1414 let list: PagedListModel<Issue>
1515 let repoPath: String
16 private let client: GitbayClient
1617 var filter: StateFilter = .open {
1718 didSet {
1819 guard filter != oldValue else { return }
@@ -21,7 +22,14 @@ final class IssueListViewModel {
2122 }
2223 }
2324
25 /// This repository's labels, loaded beside the issues: the names for
26 /// a picker, the colours for the chips. A failure here is not fatal —
27 /// every chip then derives its colour from its name.
28 private(set) var labels: [IssueLabel] = []
29 private(set) var colors = LabelColors()
30
2431 init(client: GitbayClient, repoPath: String) {
32 self.client = client
2533 self.repoPath = repoPath
2634 list = PagedListModel(
2735 client: client,
@@ -34,6 +42,12 @@ final class IssueListViewModel {
3442
3543 func load() async {
3644 await list.reload()
45 labels = await labelList()
46 colors = LabelColors(labels)
47 }
48
49 private func labelList() async -> [IssueLabel] {
50 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
3751 }
3852
3953 private func configureList() {
gitbay/Views/Issues/IssueListView.swift +3 −2
@@ -26,7 +26,7 @@ struct IssueListView: View {
2626
2727 ForEach(model.state.value ?? []) { issue in
2828 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
29 IssueRow(issue: issue)
29 IssueRow(issue: issue, colors: model.colors)
3030 }
3131 }
3232 PageFooter(list: model.list)
@@ -70,6 +70,7 @@ struct IssueListView: View {
7070
7171private struct IssueRow: View {
7272 let issue: Issue
73 let colors: LabelColors
7374
7475 var body: some View {
7576 VStack(alignment: .leading, spacing: 4) {
@@ -86,7 +87,7 @@ private struct IssueRow: View {
8687 .font(.gbSans(.caption2))
8788 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
8889 ForEach(issue.labels ?? [], id: \.self) { label in
89 GBChip(label, .secondary)
90 GBChip(label, colors.color(label))
9091 }
9192 Spacer()
9293 if let assignees = issue.assignees, !assignees.isEmpty {
gitbay/Views/Issues/IssueView.swift +8 −4
@@ -88,7 +88,7 @@ struct IssueView: View {
8888 @ViewBuilder
8989 private func triageSection(_ issue: IssueDetail) -> some View {
9090 Section("Labels") {
91 labelFlow(issue.labels ?? [], remove: { label in
91 labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
9292 Task { await model.removeLabel(label) }
9393 })
9494 HStack {
@@ -150,7 +150,11 @@ struct IssueView: View {
150150 }
151151
152152 @ViewBuilder
153 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
153 private func labelFlow(
154 _ items: [String],
155 color: @escaping (String) -> Color = { _ in .secondary },
156 remove: @escaping (String) -> Void
157 ) -> some View {
154158 if !items.isEmpty {
155159 ScrollView(.horizontal, showsIndicators: false) {
156160 HStack(spacing: 6) {
@@ -168,8 +172,8 @@ struct IssueView: View {
168172 .font(.gbSans(.caption))
169173 .padding(.horizontal, 8)
170174 .padding(.vertical, 3)
171 .background(Color.secondary.opacity(0.07), in: gbChipShape)
172 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
175 .background(color(item).opacity(0.07), in: gbChipShape)
176 .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1))
173177 }
174178 }
175179 }
gitbayTests/IssueBuildViewModelTests.swift +1 −1
@@ -84,7 +84,7 @@ struct IssueDetailViewModelTests {
8484
8585 await model.comment("triaged from the phone")
8686
87 let write = stub.seen[1]
87 let write = try #require(stub.seen.first { $0.method == "POST" })
8888 #expect(write.method == "POST")
8989 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
9090 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"])
gitbayTests/LabelTests.swift +85
@@ -98,3 +98,88 @@ struct LabelColorsTests {
9898 #expect(LabelColors().hex("docs") == "#9a6700")
9999 }
100100}
101
102private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
103 let box = StubProtocol.box()
104 let client = GitbayClient(
105 instance: try GitbayInstance(url: "https://gitbay.org"),
106 token: "test-token",
107 session: box.session()
108 )
109 return (client, box)
110}
111
112private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
113 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
114 return try #require(body["argv"] as? [String])
115}
116
117private let issuePageJSON = """
118 {"protocol_version":1,"data":{"items":[\
119 {"number":7,"title":"a bug","state":"open","author":"cmc",\
120 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
121 """
122private let labelListJSON = """
123 {"protocol_version":1,"data":[\
124 {"name":"bug","color":"#cf222e","issues":18},\
125 {"name":"ci","issues":11}],"exit_code":0}
126 """
127
128@MainActor
129struct IssueListColorsTests {
130
131 @Test func loadsLabelsAlongsideTheIssues() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
134 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
135
136 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
137 await model.load()
138
139 #expect(model.state.value?.count == 1)
140 // The names are what MR 2's filter picker will offer.
141 #expect(model.labels.map(\.name) == ["bug", "ci"])
142 #expect(model.colors.hex("bug") == "#cf222e")
143 #expect(model.colors.hex("ci") == "#1a7f37")
144 #expect(stub.seen.contains {
145 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
146 })
147 }
148
149 /// Decoration must not take the screen down with it.
150 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
151 let (client, stub) = try makeClient()
152 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
153 stub.enqueue(.init(status: 403, json: """
154 {"protocol_version":1,"error":"denied","exit_code":4}
155 """, match: "argv=label"))
156
157 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
158 await model.load()
159
160 #expect(model.state.value?.count == 1)
161 #expect(model.labels.isEmpty)
162 // Derived from the name, since nothing was stored.
163 #expect(model.colors.hex("bug") == "#b93a86")
164 }
165}
166
167@MainActor
168struct IssueDetailColorsTests {
169
170 @Test func loadsLabelsAlongsideTheIssue() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: """
173 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
174 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
175 "exit_code":0}
176 """, match: "argv=show"))
177 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
178
179 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
180 await model.load()
181
182 #expect(model.state.value?.number == 7)
183 #expect(model.colors.hex("bug") == "#cf222e")
184 }
185}