docs/superpowers/plans/2026-09-06-mr01-labels.md

v1.10.0
gitbay-ios/docs/superpowers/plans/2026-09-06-mr01-labels.md rendered · source · history · blame · raw

1091 lines · 40663 bytes

   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.