Commit 82af2bbc90

82af2bbc9019969c512fb48d3cd33c69c89a0533

parent: 6c3141f419

Verified · cmc

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

Implementation plan for MR 1: labels

Five tasks: the colour port with golden values from the web's algorithm,
chips on the issue list and detail, the management view model, the
screen and its route, then the parity row.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr01-labels.md added +1091
@@ -0,0 +1,1091 @@
1# MR 1: Labels — list, colour, management Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the `labels: list, colour` parity row — paint every label chip
6the colour the web paints it, and add a screen that creates, recolours and
7removes labels.
8
9**Architecture:** An `IssueLabel` wire model plus a pure colour function ported
10from the web's `labelColors`/`clampChip`, so the same label reads the same on
11both surfaces. Chip colours reach the issue list and issue detail through a
12`LabelColors` value type each view model builds from one `label list` read — a
13value, not a shared reference. Each view model also keeps the raw
14`[IssueLabel]`, because MR 2's filter picker needs the names, not just the
15colours. The management screen follows the existing milestone screen's
16model/view-model/view split exactly.
17
18**Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5,
19SwiftUI, Swift Testing (not XCTest), `@Observable` view models.
20
21**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
22
23## Global Constraints
24
25- Swift 6 language mode, default `MainActor` isolation. Wire models are
26 `nonisolated struct`s.
27- Swift Testing (`import Testing`, `@Test`, `#expect`, `#require`) — never
28 XCTest.
29- `gitbayTests` is hermetic and runs offline. Network goes through
30 `StubProtocol`; never add a test that reaches the network.
31- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj`
32 edit — both are `PBXFileSystemSynchronizedRootGroup`s
33 (`gitbay.xcodeproj/project.pbxproj:39-48`), so Xcode picks them up from the
34 filesystem.
35- **The model is `IssueLabel`, never `Label`.** SwiftUI's `Label` is used 32
36 times across `gitbay/`. A type named `Label` in this module would shadow it
37 everywhere and break all of them.
38- The app adds no capability the CLI lacks. Every command here already exists:
39 `label list <owner/name>`, `label set <owner/name> <label> [--color rrggbb|'']`,
40 `label remove <owner/name> <label>`.
41- Never mention Claude, LLMs or AI in commits, code comments, or the merge
42 request. No `Co-Authored-By` trailer.
43- Never commit to `main`. This work happens on a branch and lands as a merge
44 request.
45
46**Build and test command** (used by every "run the tests" step):
47
48```bash
49xcodebuild -project gitbay.xcodeproj -scheme gitbay \
50 -destination 'platform=iOS Simulator,name=iPhone 17' \
51 test -only-testing:gitbayTests
52```
53
54To run one suite, append the suite name:
55`-only-testing:gitbayTests/LabelPaletteTests`.
56
57---
58
59## File Structure
60
61| File | Responsibility |
62|------|----------------|
63| `gitbay/Issues/IssueLabel.swift` (create) | `IssueLabel` wire model, `LabelPalette` colour derivation, `LabelColors` lookup |
64| `gitbay/Issues/LabelListViewModel.swift` (create) | Load, create/recolour, remove — the management screen's state |
65| `gitbay/Views/Issues/LabelListView.swift` (create) | The management screen |
66| `gitbayTests/LabelTests.swift` (create) | Every test in this plan |
67| `gitbay/Views/Repos/RepoRoute.swift` (modify) | `labels(repo:)` case |
68| `gitbay/ContentView.swift` (modify) | Route to `LabelListView` |
69| `gitbay/Views/Repos/RepoView.swift` (modify) | Entry point beside Milestones |
70| `gitbay/Issues/IssueListViewModel.swift` (modify) | Hold `client`, expose `labels` and `colors` |
71| `gitbay/Issues/IssueDetailViewModel.swift` (modify) | Expose `labels` and `colors` |
72| `gitbay/Views/Issues/IssueListView.swift` (modify) | Coloured chips |
73| `gitbay/Views/Issues/IssueView.swift` (modify) | Coloured chips |
74
75---
76
77### Task 1: Label model and colour derivation
78
79Pure logic, no network. This is the task worth the most care: the colours have
80to match the web's exactly, and the golden values below were computed from the
81web's own algorithm.
82
83**Background — what the web does** (`krz/gitbay`,
84`internal/httpd/web.go:1552-1568` and `1512-1545`):
85
861. If the stored colour matches `^#[0-9a-fA-F]{6}$`, use it.
872. Otherwise pick `labelPalette[fnv1a32(name) % 8]`.
883. Either way, run it through `clampChip`, which holds relative luminance
89 between 0.12 and 0.28 by scaling the channels in linear light, keeping the
90 hue.
91
92Every palette entry already sits inside that window (Y ranges 0.1235 to
930.1862), so a *derived* colour is never adjusted. Clamping only ever changes a
94*stored* colour. Implement it that way and the palette path stays a plain index.
95
96`label set` normalises what it stores to lowercase with a leading `#`
97(`internal/control/label.go:66`), so a stored value in any other shape is
98treated as unset.
99
100**Files:**
101- Create: `gitbay/Issues/IssueLabel.swift`
102- Test: `gitbayTests/LabelTests.swift`
103
104**Interfaces:**
105- Consumes: `UIColor.init(hex: UInt32)` (`gitbay/Theme/Theme.swift:44`)
106- Produces:
107 - `struct IssueLabel: Decodable, Sendable, Hashable, Identifiable` with
108 `name: String`, `color: String?`, `issues: Int`, `id: String { name }`
109 - `enum LabelPalette` with `static let palette: [String]` and
110 `static func hex(name: String, stored: String?) -> String`
111 - `struct LabelColors` with `init(_ labels: [IssueLabel] = [])`,
112 `func hex(_ name: String) -> String`, `func color(_ name: String) -> Color`
113
114- [ ] **Step 1: Write the failing tests**
115
116Create `gitbayTests/LabelTests.swift`:
117
118```swift
119import Foundation
120import Testing
121@testable import gitbay
122
123struct LabelPaletteTests {
124
125 // Golden values computed from the web's own algorithm: FNV-1a 32 of
126 // the name, modulo the eight-colour palette. If these drift, the two
127 // surfaces paint the same label differently.
128 @Test func derivesAStableColourFromTheName() {
129 #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
130 #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
131 #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
132 #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
133 #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
134 }
135
136 @Test func theSameNameAlwaysDerivesTheSameColour() {
137 #expect(LabelPalette.hex(name: "bug", stored: nil)
138 == LabelPalette.hex(name: "bug", stored: nil))
139 }
140
141 /// A stored colour already inside the luminance window is used as-is.
142 @Test func keepsAStoredColourThatIsAlreadyLegible() {
143 #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
144 #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
145 }
146
147 /// Too dark or too bright is scaled back into the window, so a chip
148 /// stays readable as text on both grounds.
149 @Test func clampsAStoredColourOutsideTheWindow() {
150 #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
151 #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
152 }
153
154 @Test func upperCaseStoredColoursNormaliseDown() {
155 #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
156 }
157
158 /// `label set` stores `#rrggbb`. Anything else — an empty string, a
159 /// bare six digits, a name — never reached the store as a colour, so
160 /// it is treated as unset and the name derives one.
161 @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
162 let derived = LabelPalette.hex(name: "bug", stored: nil)
163 #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
164 #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
165 #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
166 #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
167 }
168
169 @Test func everyPaletteEntryIsAlreadyLegible() {
170 // No palette colour is ever clamped, so a derived colour is a
171 // plain lookup. If this fails the palette drifted from the web's.
172 for hex in LabelPalette.palette {
173 #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
174 }
175 }
176}
177
178struct IssueLabelDecodingTests {
179
180 @Test func decodesAListRow() throws {
181 let json = Data("""
182 [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
183 """.utf8)
184 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
185
186 #expect(labels.count == 2)
187 #expect(labels[0].name == "bug")
188 #expect(labels[0].color == "#cf222e")
189 #expect(labels[0].issues == 18)
190 // `color` is omitempty on the wire — absent, not null, when unset.
191 #expect(labels[1].color == nil)
192 #expect(labels[1].issues == 11)
193 }
194
195 @Test func theIdIsTheName() throws {
196 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
197 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
198 #expect(labels[0].id == "bug")
199 }
200}
201
202struct LabelColorsTests {
203
204 @Test func looksUpAStoredColour() {
205 let colors = LabelColors([
206 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
207 IssueLabel(name: "ci", color: nil, issues: 2),
208 ])
209 #expect(colors.hex("bug") == "#cf222e")
210 #expect(colors.hex("ci") == "#1a7f37")
211 }
212
213 /// An issue can carry a label the map has not loaded — a stale list, or
214 /// a label added since. It still derives a colour rather than going grey.
215 @Test func anUnknownLabelStillDerivesItsColour() {
216 #expect(LabelColors().hex("docs") == "#9a6700")
217 }
218}
219```
220
221- [ ] **Step 2: Run the tests to verify they fail**
222
223Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelPaletteTests`
224
225Expected: the build fails — `cannot find 'LabelPalette' in scope`, `cannot find
226type 'IssueLabel' in scope`, `cannot find 'LabelColors' in scope`.
227
228- [ ] **Step 3: Write the implementation**
229
230Create `gitbay/Issues/IssueLabel.swift`:
231
232```swift
233import SwiftUI
234
235/// One row of `label list`: the label, the colour it was set to, and how
236/// many issues carry it.
237///
238/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
239/// used throughout this module and a bare `Label` here would shadow it.
240nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
241 let name: String
242 /// `#rrggbb` when set, absent otherwise — `label set` normalises to
243 /// lowercase with the hash, so any other shape never reached the store.
244 let color: String?
245 let issues: Int
246
247 var id: String { name }
248}
249
250/// Chip colours for labels, matching what the web paints
251/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
252/// label reads the same on both surfaces.
253nonisolated enum LabelPalette {
254
255 /// Mid-tone hues that stay legible as text on both grounds. Every
256 /// entry already sits inside the window `clamped` holds, so a derived
257 /// colour is a plain lookup and only a stored one is ever adjusted.
258 static let palette = [
259 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
260 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
261 ]
262
263 /// The colour the web would paint for this label: the stored one when
264 /// it is a hex colour, otherwise one picked from the palette by name.
265 static func hex(name: String, stored: String?) -> String {
266 guard let stored, let parsed = parse(stored) else {
267 return palette[Int(fnv1a(name) % UInt32(palette.count))]
268 }
269 return clamped(parsed)
270 }
271
272 /// `#rrggbb` to its three channels, or nil for anything else.
273 private static func parse(_ hex: String) -> (Double, Double, Double)? {
274 guard hex.count == 7, hex.hasPrefix("#") else { return nil }
275 let digits = hex.dropFirst()
276 guard digits.allSatisfy(\.isHexDigit),
277 let value = UInt32(digits, radix: 16) else { return nil }
278 return (
279 Double((value >> 16) & 0xFF) / 255,
280 Double((value >> 8) & 0xFF) / 255,
281 Double(value & 0xFF) / 255
282 )
283 }
284
285 /// Hold relative luminance between 0.12 and 0.28, where a chip clears
286 /// 3:1 against white and against the dark ground alike. The hue is
287 /// kept; the channels are scaled in linear light.
288 private static func clamped(_ rgb: (Double, Double, Double)) -> String {
289 let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
290 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
291 let lo = 0.12, hi = 0.28
292 if y >= lo, y <= hi { return format(r, g, b) }
293 let target = y < lo ? lo : hi
294 guard y != 0 else { return format(target, target, target) }
295 let k = target / y
296 return format(min(1, r * k), min(1, g * k), min(1, b * k))
297 }
298
299 private static func linear(_ c: Double) -> Double {
300 c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
301 }
302
303 private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
304 func encode(_ v: Double) -> Int {
305 let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
306 return Int((s * 255).rounded())
307 }
308 return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
309 }
310
311 /// FNV-1a, 32-bit. The web hashes the name with it to index the
312 /// palette, so this has to agree bit for bit.
313 private static func fnv1a(_ text: String) -> UInt32 {
314 var hash: UInt32 = 2_166_136_261
315 for byte in text.utf8 {
316 hash ^= UInt32(byte)
317 hash &*= 16_777_619
318 }
319 return hash
320 }
321}
322
323/// The chip colour for every label in one repository. Built from one
324/// `label list` read; a label it has never heard of still derives a
325/// colour from its name rather than falling back to grey.
326nonisolated struct LabelColors: Sendable, Hashable {
327
328 private let stored: [String: String]
329
330 init(_ labels: [IssueLabel] = []) {
331 stored = labels.reduce(into: [:]) { map, label in
332 map[label.name] = label.color ?? ""
333 }
334 }
335
336 func hex(_ name: String) -> String {
337 LabelPalette.hex(name: name, stored: stored[name])
338 }
339
340 func color(_ name: String) -> Color {
341 guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
342 return .secondary
343 }
344 return Color(UIColor(hex: value))
345 }
346}
347```
348
349`format` runs on every stored colour, including ones already inside the window.
350Round-tripping through linear light and back is lossless to the nearest byte,
351which the `#ff0000` and `#0969da` tests assert.
352
353- [ ] **Step 4: Run the tests to verify they pass**
354
355Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
356
357Expected: PASS, including every pre-existing suite.
358
359- [ ] **Step 5: Commit**
360
361```bash
362git add gitbay/Issues/IssueLabel.swift gitbayTests/LabelTests.swift
363git commit -m "Label model and the web's chip colours"
364```
365
366---
367
368### Task 2: Colour the chips on the issue list and issue detail
369
370Labels render as `GBChip(label, .secondary)` today — one flat colour, and only
371on the list. This task loads `label list` alongside the issues and paints both
372screens.
373
374**Files:**
375- Modify: `gitbay/Issues/IssueListViewModel.swift`
376- Modify: `gitbay/Issues/IssueDetailViewModel.swift`
377- Modify: `gitbay/Views/Issues/IssueListView.swift` (`:29` and `:71-92`)
378- Modify: `gitbay/Views/Issues/IssueView.swift` (`labelFlow`, called at `:91`
379 for labels and `:109` for assignees)
380- Test: `gitbayTests/LabelTests.swift`
381
382**Interfaces:**
383- Consumes: `LabelColors`, `IssueLabel` (Task 1); `GitbayClient.readList(_:of:)`
384- Produces:
385 - `IssueListViewModel.labels: [IssueLabel]`, `IssueListViewModel.colors: LabelColors`
386 - `IssueDetailViewModel.labels: [IssueLabel]`, `IssueDetailViewModel.colors: LabelColors`
387
388Both view models keep the raw `labels` array as well as the derived `colors`.
389MR 2's label filter picks from `labels`; only the chips need `colors`.
390
391The colour read is deliberately not fatal: a repository the caller can read
392issues in but not labels, or a `label list` that fails for any reason, leaves
393both properties at their empty defaults and every chip derives from its name.
394An issue list that refuses to load because its decoration failed would be a
395worse screen than one with derived colours.
396
397**Note:** `IssueListViewModel` does not currently hold `client` — it passes it
398into `PagedListModel` and drops it (`IssueListViewModel.swift:25-30`). It needs
399a stored `client` now.
400
401- [ ] **Step 1: Write the failing tests**
402
403Append to `gitbayTests/LabelTests.swift`:
404
405```swift
406private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
407 let box = StubProtocol.box()
408 let client = GitbayClient(
409 instance: try GitbayInstance(url: "https://gitbay.org"),
410 token: "test-token",
411 session: box.session()
412 )
413 return (client, box)
414}
415
416private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
417 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
418 return try #require(body["argv"] as? [String])
419}
420
421private let issuePageJSON = """
422 {"protocol_version":1,"data":{"items":[\
423 {"number":7,"title":"a bug","state":"open","author":"cmc",\
424 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
425 """
426private let labelListJSON = """
427 {"protocol_version":1,"data":[\
428 {"name":"bug","color":"#cf222e","issues":18},\
429 {"name":"ci","issues":11}],"exit_code":0}
430 """
431
432@MainActor
433struct IssueListColorsTests {
434
435 @Test func loadsLabelsAlongsideTheIssues() async throws {
436 let (client, stub) = try makeClient()
437 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
438 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
439
440 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
441 await model.load()
442
443 #expect(model.state.value?.count == 1)
444 // The names are what MR 2's filter picker will offer.
445 #expect(model.labels.map(\.name) == ["bug", "ci"])
446 #expect(model.colors.hex("bug") == "#cf222e")
447 #expect(model.colors.hex("ci") == "#1a7f37")
448 #expect(stub.seen.contains {
449 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
450 })
451 }
452
453 /// Decoration must not take the screen down with it.
454 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
455 let (client, stub) = try makeClient()
456 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
457 stub.enqueue(.init(status: 403, json: """
458 {"protocol_version":1,"error":"denied","exit_code":4}
459 """, match: "argv=label"))
460
461 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
462 await model.load()
463
464 #expect(model.state.value?.count == 1)
465 #expect(model.labels.isEmpty)
466 // Derived from the name, since nothing was stored.
467 #expect(model.colors.hex("bug") == "#b93a86")
468 }
469}
470
471@MainActor
472struct IssueDetailColorsTests {
473
474 @Test func loadsLabelsAlongsideTheIssue() async throws {
475 let (client, stub) = try makeClient()
476 stub.enqueue(.init(status: 200, json: """
477 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
478 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
479 "exit_code":0}
480 """, match: "argv=show"))
481 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
482
483 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
484 await model.load()
485
486 #expect(model.state.value?.number == 7)
487 #expect(model.colors.hex("bug") == "#cf222e")
488 }
489}
490```
491
492- [ ] **Step 2: Run the tests to verify they fail**
493
494Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/IssueListColorsTests`
495
496Expected: build failure — `value of type 'IssueListViewModel' has no member 'labels'`.
497
498- [ ] **Step 3: Add `labels` and `colors` to `IssueListViewModel`**
499
500In `gitbay/Issues/IssueListViewModel.swift`, store the client, add the two
501properties, and load the labels beside the issues:
502
503```swift
504 let list: PagedListModel<Issue>
505 let repoPath: String
506 private let client: GitbayClient
507
508 /// This repository's labels, loaded beside the issues: the names for
509 /// a picker, the colours for the chips. A failure here is not fatal —
510 /// every chip then derives its colour from its name.
511 private(set) var labels: [IssueLabel] = []
512 private(set) var colors = LabelColors()
513
514 init(client: GitbayClient, repoPath: String) {
515 self.client = client
516 self.repoPath = repoPath
517 list = PagedListModel(
518 client: client,
519 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
520 emptyMessage: "No open issues."
521 )
522 }
523
524 func load() async {
525 async let issues: Void = list.reload()
526 async let loaded = labelList()
527 _ = await issues
528 labels = await loaded
529 colors = LabelColors(labels)
530 }
531
532 private func labelList() async -> [IssueLabel] {
533 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
534 }
535```
536
537- [ ] **Step 4: Add the same to `IssueDetailViewModel`**
538
539It already holds `client` and `repoPath` (`IssueDetailViewModel.swift:15-17`),
540so only the properties and the load change:
541
542```swift
543 private(set) var labels: [IssueLabel] = []
544 private(set) var colors = LabelColors()
545
546 func load() async {
547 async let loaded = labelList()
548 do {
549 state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
550 } catch {
551 state = .from(error)
552 }
553 labels = await loaded
554 colors = LabelColors(labels)
555 }
556
557 private func labelList() async -> [IssueLabel] {
558 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
559 }
560```
561
562- [ ] **Step 5: Run the tests to verify they pass**
563
564Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
565
566Expected: PASS.
567
568- [ ] **Step 6: Paint the chips on the issue list**
569
570In `gitbay/Views/Issues/IssueListView.swift`, pass the map into the row at `:29`:
571
572```swift
573 IssueRow(issue: issue, colors: model.colors)
574```
575
576Add the property to `IssueRow` at `:71`:
577
578```swift
579private struct IssueRow: View {
580 let issue: Issue
581 let colors: LabelColors
582```
583
584And colour the chip at `:89`:
585
586```swift
587 ForEach(issue.labels ?? [], id: \.self) { label in
588 GBChip(label, colors.color(label))
589 }
590```
591
592- [ ] **Step 7: Paint the chips on the issue detail**
593
594`labelFlow` in `gitbay/Views/Issues/IssueView.swift` renders both labels (`:91`)
595and assignees (`:109`), and only labels are coloured. Give it a colour closure
596defaulting to the current behaviour:
597
598```swift
599 private func labelFlow(
600 _ items: [String],
601 color: (String) -> Color = { _ in .secondary },
602 remove: @escaping (String) -> Void
603 ) -> some View {
604```
605
606Inside, replace the chip's `.secondary` with `color(item)`. Then pass the map at
607the labels call site only:
608
609```swift
610 labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
611 Task { await model.removeLabel(label) }
612 })
613```
614
615Leave the assignees call site at `:109` untouched — it takes the default.
616
617- [ ] **Step 8: Build and run the whole suite**
618
619Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
620
621Expected: PASS. Then launch the app and confirm the issue list and an issue's
622detail both show coloured chips against a real repository.
623
624- [ ] **Step 9: Commit**
625
626```bash
627git add gitbay/Issues/IssueListViewModel.swift gitbay/Issues/IssueDetailViewModel.swift \
628 gitbay/Views/Issues/IssueListView.swift gitbay/Views/Issues/IssueView.swift \
629 gitbayTests/LabelTests.swift
630git commit -m "Paint label chips the colour the web paints them"
631```
632
633---
634
635### Task 3: The label management view model
636
637**Files:**
638- Create: `gitbay/Issues/LabelListViewModel.swift`
639- Test: `gitbayTests/LabelTests.swift`
640
641**Interfaces:**
642- Consumes: `IssueLabel` (Task 1), `LoadState`, `GitbayClient.readList(_:of:)`,
643 `GitbayClient.run(_:stdin:)`
644- Produces: `LabelListViewModel` with `state: LoadState<[IssueLabel]>`,
645 `actionError: String?`, `working: Bool`, `repoPath: String`,
646 `load()`, `setLabel(_ name: String, color: String?)`, `remove(_ name: String)`
647
648Follows `MilestoneListViewModel` for the load half and
649`RepoSettingsViewModel.perform(argv:)` (`:89-101`) for the write half, copied
650verbatim. It is eight lines, and the two existing copies are not this merge
651request's to refactor.
652
653- [ ] **Step 1: Write the failing tests**
654
655Append to `gitbayTests/LabelTests.swift`:
656
657```swift
658@MainActor
659struct LabelListViewModelTests {
660
661 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
662 let (client, stub) = try makeClient()
663 stub.enqueue(.init(status: 200, json: labelListJSON))
664 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
665 await model.load()
666 return (model, stub)
667 }
668
669 @Test func listsLabelsWithTheirUseCounts() async throws {
670 let (model, stub) = try await loaded()
671
672 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
673 #expect(model.state.value?.map(\.issues) == [18, 11])
674 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
675 }
676
677 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
678 let (client, stub) = try makeClient()
679 stub.enqueue(.init(status: 200, json: """
680 {"protocol_version":1,"exit_code":0}
681 """))
682 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
683 await model.load()
684
685 guard case .empty = model.state else {
686 Testing.Issue.record("expected an empty state, got \(model.state)")
687 return
688 }
689 }
690
691 @Test func settingAColourSendsTheColourFlag() async throws {
692 let (model, stub) = try await loaded()
693 stub.enqueue(.init(status: 200, json: """
694 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
695 """))
696 stub.enqueue(.init(status: 200, json: labelListJSON))
697
698 await model.setLabel("bug", color: "cf222e")
699
700 let write = try #require(stub.seen.first { $0.method == "POST" })
701 #expect(try argvOf(write)
702 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
703 }
704
705 /// A label created without a colour must not send `--color`, which
706 /// would be "clear it" rather than "leave whatever it has".
707 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
708 let (model, stub) = try await loaded()
709 stub.enqueue(.init(status: 200, json: """
710 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
711 """))
712 stub.enqueue(.init(status: 200, json: labelListJSON))
713
714 await model.setLabel("new", color: nil)
715
716 let write = try #require(stub.seen.first { $0.method == "POST" })
717 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
718 }
719
720 /// An empty string is not nil: it clears the colour.
721 @Test func clearingSendsAnEmptyColour() async throws {
722 let (model, stub) = try await loaded()
723 stub.enqueue(.init(status: 200, json: """
724 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
725 """))
726 stub.enqueue(.init(status: 200, json: labelListJSON))
727
728 await model.setLabel("bug", color: "")
729
730 let write = try #require(stub.seen.first { $0.method == "POST" })
731 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
732 }
733
734 @Test func removingSendsTheRemoveCommand() async throws {
735 let (model, stub) = try await loaded()
736 stub.enqueue(.init(status: 200, json: """
737 {"protocol_version":1,"exit_code":0}
738 """))
739 stub.enqueue(.init(status: 200, json: labelListJSON))
740
741 await model.remove("bug")
742
743 let write = try #require(stub.seen.first { $0.method == "POST" })
744 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
745 }
746
747 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
748 let (model, stub) = try await loaded()
749 stub.enqueue(.init(status: 403, json: """
750 {"protocol_version":1,"error":"write access required","exit_code":4}
751 """))
752
753 await model.remove("bug")
754
755 #expect(model.actionError?.isEmpty == false)
756 #expect(model.working == false)
757 }
758}
759```
760
761`Testing.Issue.record` is spelled with its module because this app has its own
762`Issue` model, and `@testable import gitbay` makes the app's win.
763
764- [ ] **Step 2: Run the tests to verify they fail**
765
766Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelListViewModelTests`
767
768Expected: build failure — `cannot find 'LabelListViewModel' in scope`.
769
770- [ ] **Step 3: Write the implementation**
771
772Create `gitbay/Issues/LabelListViewModel.swift`:
773
774```swift
775import Foundation
776import Observation
777
778/// `label list <repo>`, plus the two commands that change the set:
779/// `label set` creates one or recolours it, `label remove` takes it off
780/// the repository and off every issue that carried it.
781@Observable
782@MainActor
783final class LabelListViewModel {
784
785 private(set) var state: LoadState<[IssueLabel]> = .loading
786 private(set) var actionError: String?
787 private(set) var working = false
788
789 private let client: GitbayClient
790 let repoPath: String
791
792 init(client: GitbayClient, repoPath: String) {
793 self.client = client
794 self.repoPath = repoPath
795 }
796
797 func load() async {
798 state = .loading
799 do {
800 let labels = try await client.readList(
801 ["label", "list", repoPath], of: IssueLabel.self
802 )
803 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
804 } catch {
805 state = .from(error)
806 }
807 }
808
809 /// Create a label or set its colour. A nil colour omits `--color`
810 /// entirely, which the server reads as "make sure it exists" and
811 /// leaves any colour it already had; an empty string clears it.
812 func setLabel(_ name: String, color: String?) async {
813 var argv = ["label", "set", repoPath, name]
814 if let color { argv.append(contentsOf: ["--color", color]) }
815 await perform(argv)
816 }
817
818 func remove(_ name: String) async {
819 await perform(["label", "remove", repoPath, name])
820 }
821
822 private func perform(_ argv: [String]) async {
823 working = true
824 actionError = nil
825 defer { working = false }
826 do {
827 try await client.run(argv)
828 await load()
829 } catch let error as GitbayError {
830 actionError = error.userFacingMessage
831 } catch {
832 actionError = GitbayError.transport(error).userFacingMessage
833 }
834 }
835}
836```
837
838- [ ] **Step 4: Run the tests to verify they pass**
839
840Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
841
842Expected: PASS.
843
844- [ ] **Step 5: Commit**
845
846```bash
847git add gitbay/Issues/LabelListViewModel.swift gitbayTests/LabelTests.swift
848git commit -m "Label list view model: list, set colour, remove"
849```
850
851---
852
853### Task 4: The management screen and its route
854
855**Files:**
856- Create: `gitbay/Views/Issues/LabelListView.swift`
857- Modify: `gitbay/Views/Repos/RepoRoute.swift`
858- Modify: `gitbay/ContentView.swift` (the `RepoRoute` switch, beside
859 `case .milestones` at `:99`)
860- Modify: `gitbay/Views/Repos/RepoView.swift` (after the Milestones link at `:43-45`)
861
862**Interfaces:**
863- Consumes: `LabelListViewModel` (Task 3), `LabelPalette`, `LabelColors`,
864 `IssueLabel` (Task 1), `LoadStateOverlay`, `GBChip`, `GBNotice`
865- Produces: `RepoRoute.labels(repo: String)`, `LabelListView(client:repo:)`
866
867`label remove` takes the label off every issue that carried it, so removal is
868confirmed before it fires. Creating and recolouring are not.
869
870- [ ] **Step 1: Add the route case**
871
872In `gitbay/Views/Repos/RepoRoute.swift`, after `case milestones(repo: String)`:
873
874```swift
875 case labels(repo: String)
876```
877
878- [ ] **Step 2: Write the view**
879
880Create `gitbay/Views/Issues/LabelListView.swift`:
881
882```swift
883import SwiftUI
884
885/// A repository's labels: their colour, how many issues carry each, and
886/// the controls to create, recolour and remove them.
887struct LabelListView: View {
888
889 @State private var model: LabelListViewModel
890 @State private var newLabel = ""
891 @State private var pendingRemoval: IssueLabel?
892
893 init(client: GitbayClient, repo: String) {
894 _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo))
895 }
896
897 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
898
899 var body: some View {
900 List {
901 if let actionError = model.actionError {
902 Section { GBNotice(actionError) }
903 }
904
905 Section("New label") {
906 HStack {
907 TextField("Label", text: $newLabel)
908 .autocorrectionDisabled()
909 .textInputAutocapitalization(.never)
910 Button {
911 let name = newLabel.trimmingCharacters(in: .whitespaces)
912 newLabel = ""
913 Task { await model.setLabel(name, color: nil) }
914 } label: {
915 Image(systemName: "plus.circle.fill")
916 }
917 .disabled(
918 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
919 )
920 }
921 }
922
923 ForEach(model.state.value ?? []) { label in
924 LabelRow(label: label, color: colors.color(label.name), working: model.working) {
925 hex in
926 Task { await model.setLabel(label.name, color: hex) }
927 } remove: {
928 pendingRemoval = label
929 }
930 }
931 }
932 .overlay { LoadStateOverlay(state: model.state) }
933 .navigationTitle("Labels")
934 .navigationBarTitleDisplayMode(.inline)
935 .task { await model.load() }
936 .refreshable { await model.load() }
937 .confirmationDialog(
938 "Remove \(pendingRemoval?.name ?? "")?",
939 isPresented: .init(
940 get: { pendingRemoval != nil },
941 set: { if !$0 { pendingRemoval = nil } }
942 ),
943 titleVisibility: .visible
944 ) {
945 Button("Remove", role: .destructive) {
946 guard let label = pendingRemoval else { return }
947 pendingRemoval = nil
948 Task { await model.remove(label.name) }
949 }
950 Button("Cancel", role: .cancel) { pendingRemoval = nil }
951 } message: {
952 // The command takes the label off every issue, not just the set.
953 Text("This takes the label off every issue that carries it.")
954 }
955 }
956}
957
958private struct LabelRow: View {
959 let label: IssueLabel
960 let color: Color
961 let working: Bool
962 let recolour: (String) -> Void
963 let remove: () -> Void
964
965 var body: some View {
966 HStack(spacing: 8) {
967 GBChip(label.name, color)
968 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
969 .font(.gbSans(.caption))
970 .foregroundStyle(.secondary)
971 Spacer()
972 Menu {
973 ForEach(LabelPalette.palette, id: \.self) { hex in
974 Button(hex) { recolour(String(hex.dropFirst())) }
975 }
976 Button("Clear colour") { recolour("") }
977 Button("Remove", role: .destructive) { remove() }
978 } label: {
979 Image(systemName: "ellipsis.circle")
980 }
981 .disabled(working)
982 }
983 }
984}
985```
986
987- [ ] **Step 3: Wire the route**
988
989In `gitbay/ContentView.swift`, in the `RepoRoute` switch after
990`case .milestones(let repo)` at `:99-100`:
991
992```swift
993 case .labels(let repo):
994 LabelListView(client: client, repo: repo)
995```
996
997In `gitbay/Views/Repos/RepoView.swift`, after the Milestones link at `:43-45`:
998
999```swift
1000 NavigationLink(value: RepoRoute.labels(repo: path)) {
1001 Label("Labels", systemImage: "tag")
1002 }
1003```
1004
1005That `Label` is SwiftUI's, which is exactly why the model is called
1006`IssueLabel`.
1007
1008- [ ] **Step 4: Build and check the screen**
1009
1010Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
1011
1012Expected: PASS. Then launch the app against a real repository and confirm:
1013labels list with colours and counts; creating one adds it; recolouring repaints
1014the chip; removing asks first and then removes.
1015
1016- [ ] **Step 5: Commit**
1017
1018```bash
1019git add gitbay/Views/Issues/LabelListView.swift gitbay/Views/Repos/RepoRoute.swift \
1020 gitbay/ContentView.swift gitbay/Views/Repos/RepoView.swift
1021git commit -m "Label management screen"
1022```
1023
1024---
1025
1026### Task 5: Flip the parity row and open the merge request
1027
1028The wiki page is updated in the merge request that changes a row — that is what
1029the page itself requires.
1030
1031**Files:**
1032- Modify: `krz/gitbay`, `.gitbay/wiki/Parity.org`, the Issues table
1033
1034- [ ] **Step 1: Flip the row**
1035
1036In the Issues table, `labels: list, colour` becomes `yes` in the `ios` column.
1037Touch no other row — the stale-row corrections are their own merge request on
1038`krz/gitbay`, not this one.
1039
1040The wiki lives at `.gitbay/wiki/` on the default branch, so this is an edit to a
1041file in the repository — a push or `repo commit-file`, not a wiki-specific
1042command.
1043
1044- [ ] **Step 2: Run the full suite one more time**
1045
1046Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
1047
1048Expected: PASS. Record the actual count; do not claim a number you have not seen.
1049
1050- [ ] **Step 3: Open the merge request**
1051
1052```bash
1053gitbay mr create --source labels-colour --target main \
1054 --title "Labels: list, colour, and a management screen" --file - <<'EOF'
1055`label list` reaches the app, so chips carry the colour the web paints
1056them: the stored `#rrggbb` when set, otherwise one picked from the same
1057eight-colour palette by an FNV-1a hash of the name, clamped to the same
1058luminance window. The issue list and issue detail both paint them.
1059
1060A Labels screen under the repository creates a label, sets its colour and
1061removes one, dispatching `label set` and `label remove`. Removal is
1062confirmed first, since it takes the label off every issue.
1063
1064Closes the `labels: list, colour` parity row.
1065EOF
1066```
1067
1068---
1069
1070## Notes for whoever executes this
1071
1072**The colour tests are the ones that matter.** Everything else here is
1073scaffolding around a screen. If the golden values in Task 1 fail, do not adjust
1074them to match the code — they were computed from the web's algorithm, and a
1075mismatch means the port is wrong, not the expectation.
1076
1077**`Label` is taken.** SwiftUI's `Label` is used 32 times in `gitbay/`. The model
1078is `IssueLabel` for that reason; do not rename it back.
1079
1080**`Issue` is ambiguous in tests.** This app has an `Issue` model and Swift
1081Testing has an `Issue` type for recording failures. Under `@testable import
1082gitbay` the app's wins, so spell Swift Testing's `Testing.Issue`.
1083
1084**`label set` without `--color` is not the same as with an empty one.** Omitted
1085means "make sure this label exists, keep any colour it has". `--color ''` clears
1086it. The view model models this as `nil` vs `""` and three tests pin the
1087difference.
1088
1089**What MR 2 consumes from this.** `IssueListViewModel.labels` is the array MR
10902's filter picker offers, and `IssueListViewModel.colors` paints the chips
1091inside it. Neither is private; do not narrow them.