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

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

1091 lines · 40663 bytes

MR 1: Labels — list, colour, management Implementation Plan

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.

Goal: Close the labels: list, colour parity row — paint every label chip the colour the web paints it, and add a screen that creates, recolours and removes labels.

Architecture: An IssueLabel wire model plus a pure colour function ported from the web's labelColors/clampChip, so the same label reads the same on both surfaces. Chip colours reach the issue list and issue detail through a LabelColors value type each view model builds from one label list read — a value, not a shared reference. Each view model also keeps the raw [IssueLabel], because MR 2's filter picker needs the names, not just the colours. The management screen follows the existing milestone screen's model/view-model/view split exactly.

Tech Stack: Swift 6 language mode, default MainActor isolation, iOS 26.5, SwiftUI, Swift Testing (not XCTest), @Observable view models.

Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md

Global Constraints

  • Swift 6 language mode, default MainActor isolation. Wire models are nonisolated structs.
  • Swift Testing (import Testing, @Test, #expect, #require) — never XCTest.
  • gitbayTests is hermetic and runs offline. Network goes through StubProtocol; never add a test that reaches the network.
  • New files under gitbay/ and gitbayTests/ need no project.pbxproj edit — both are PBXFileSystemSynchronizedRootGroups (gitbay.xcodeproj/project.pbxproj:39-48), so Xcode picks them up from the filesystem.
  • The model is IssueLabel, never Label. SwiftUI's Label is used 32 times across gitbay/. A type named Label in this module would shadow it everywhere and break all of them.
  • The app adds no capability the CLI lacks. Every command here already exists: label list <owner/name>, label set <owner/name> <label> [--color rrggbb|''], label remove <owner/name> <label>.
  • Never mention Claude, LLMs or AI in commits, code comments, or the merge request. No Co-Authored-By trailer.
  • Never commit to main. This work happens on a branch and lands as a merge request.

Build and test command (used by every "run the tests" step):

xcodebuild -project gitbay.xcodeproj -scheme gitbay \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  test -only-testing:gitbayTests

To run one suite, append the suite name: -only-testing:gitbayTests/LabelPaletteTests.


File Structure

File Responsibility
gitbay/Issues/IssueLabel.swift (create) IssueLabel wire model, LabelPalette colour derivation, LabelColors lookup
gitbay/Issues/LabelListViewModel.swift (create) Load, create/recolour, remove — the management screen's state
gitbay/Views/Issues/LabelListView.swift (create) The management screen
gitbayTests/LabelTests.swift (create) Every test in this plan
gitbay/Views/Repos/RepoRoute.swift (modify) labels(repo:) case
gitbay/ContentView.swift (modify) Route to LabelListView
gitbay/Views/Repos/RepoView.swift (modify) Entry point beside Milestones
gitbay/Issues/IssueListViewModel.swift (modify) Hold client, expose labels and colors
gitbay/Issues/IssueDetailViewModel.swift (modify) Expose labels and colors
gitbay/Views/Issues/IssueListView.swift (modify) Coloured chips
gitbay/Views/Issues/IssueView.swift (modify) Coloured chips

Task 1: Label model and colour derivation

Pure logic, no network. This is the task worth the most care: the colours have to match the web's exactly, and the golden values below were computed from the web's own algorithm.

Background — what the web does (krz/gitbay, internal/httpd/web.go:1552-1568 and 1512-1545):

  1. If the stored colour matches ^#[0-9a-fA-F]{6}$, use it.
  2. Otherwise pick labelPalette[fnv1a32(name) % 8].
  3. Either way, run it through clampChip, which holds relative luminance between 0.12 and 0.28 by scaling the channels in linear light, keeping the hue.

Every palette entry already sits inside that window (Y ranges 0.1235 to 0.1862), so a derived colour is never adjusted. Clamping only ever changes a stored colour. Implement it that way and the palette path stays a plain index.

label set normalises what it stores to lowercase with a leading # (internal/control/label.go:66), so a stored value in any other shape is treated as unset.

Files:

  • Create: gitbay/Issues/IssueLabel.swift
  • Test: gitbayTests/LabelTests.swift

Interfaces:

  • Consumes: UIColor.init(hex: UInt32) (gitbay/Theme/Theme.swift:44)

  • Produces:

    • struct IssueLabel: Decodable, Sendable, Hashable, Identifiable with name: String, color: String?, issues: Int, id: String { name }
    • enum LabelPalette with static let palette: [String] and static func hex(name: String, stored: String?) -> String
    • struct LabelColors with init(_ labels: [IssueLabel] = []), func hex(_ name: String) -> String, func color(_ name: String) -> Color
  • Step 1: Write the failing tests

Create gitbayTests/LabelTests.swift:

import Foundation
import Testing
@testable import gitbay

struct LabelPaletteTests {

    // Golden values computed from the web's own algorithm: FNV-1a 32 of
    // the name, modulo the eight-colour palette. If these drift, the two
    // surfaces paint the same label differently.
    @Test func derivesAStableColourFromTheName() {
        #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
        #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
        #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
        #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
        #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
    }

    @Test func theSameNameAlwaysDerivesTheSameColour() {
        #expect(LabelPalette.hex(name: "bug", stored: nil)
            == LabelPalette.hex(name: "bug", stored: nil))
    }

    /// A stored colour already inside the luminance window is used as-is.
    @Test func keepsAStoredColourThatIsAlreadyLegible() {
        #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
        #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
    }

    /// Too dark or too bright is scaled back into the window, so a chip
    /// stays readable as text on both grounds.
    @Test func clampsAStoredColourOutsideTheWindow() {
        #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
        #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
    }

    @Test func upperCaseStoredColoursNormaliseDown() {
        #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
    }

    /// `label set` stores `#rrggbb`. Anything else — an empty string, a
    /// bare six digits, a name — never reached the store as a colour, so
    /// it is treated as unset and the name derives one.
    @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
        let derived = LabelPalette.hex(name: "bug", stored: nil)
        #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
        #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
        #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
        #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
    }

    @Test func everyPaletteEntryIsAlreadyLegible() {
        // No palette colour is ever clamped, so a derived colour is a
        // plain lookup. If this fails the palette drifted from the web's.
        for hex in LabelPalette.palette {
            #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
        }
    }
}

struct IssueLabelDecodingTests {

    @Test func decodesAListRow() throws {
        let json = Data("""
            [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
            """.utf8)
        let labels = try JSONDecoder().decode([IssueLabel].self, from: json)

        #expect(labels.count == 2)
        #expect(labels[0].name == "bug")
        #expect(labels[0].color == "#cf222e")
        #expect(labels[0].issues == 18)
        // `color` is omitempty on the wire — absent, not null, when unset.
        #expect(labels[1].color == nil)
        #expect(labels[1].issues == 11)
    }

    @Test func theIdIsTheName() throws {
        let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
        let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
        #expect(labels[0].id == "bug")
    }
}

struct LabelColorsTests {

    @Test func looksUpAStoredColour() {
        let colors = LabelColors([
            IssueLabel(name: "bug", color: "#cf222e", issues: 1),
            IssueLabel(name: "ci", color: nil, issues: 2),
        ])
        #expect(colors.hex("bug") == "#cf222e")
        #expect(colors.hex("ci") == "#1a7f37")
    }

    /// An issue can carry a label the map has not loaded — a stale list, or
    /// a label added since. It still derives a colour rather than going grey.
    @Test func anUnknownLabelStillDerivesItsColour() {
        #expect(LabelColors().hex("docs") == "#9a6700")
    }
}
  • Step 2: Run the tests to verify they fail

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelPaletteTests

Expected: the build fails — cannot find 'LabelPalette' in scope, cannot find type 'IssueLabel' in scope, cannot find 'LabelColors' in scope.

  • Step 3: Write the implementation

Create gitbay/Issues/IssueLabel.swift:

import SwiftUI

/// One row of `label list`: the label, the colour it was set to, and how
/// many issues carry it.
///
/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
/// used throughout this module and a bare `Label` here would shadow it.
nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
    let name: String
    /// `#rrggbb` when set, absent otherwise — `label set` normalises to
    /// lowercase with the hash, so any other shape never reached the store.
    let color: String?
    let issues: Int

    var id: String { name }
}

/// Chip colours for labels, matching what the web paints
/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
/// label reads the same on both surfaces.
nonisolated enum LabelPalette {

    /// Mid-tone hues that stay legible as text on both grounds. Every
    /// entry already sits inside the window `clamped` holds, so a derived
    /// colour is a plain lookup and only a stored one is ever adjusted.
    static let palette = [
        "#0969da", "#1a7f37", "#9a6700", "#cf222e",
        "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
    ]

    /// The colour the web would paint for this label: the stored one when
    /// it is a hex colour, otherwise one picked from the palette by name.
    static func hex(name: String, stored: String?) -> String {
        guard let stored, let parsed = parse(stored) else {
            return palette[Int(fnv1a(name) % UInt32(palette.count))]
        }
        return clamped(parsed)
    }

    /// `#rrggbb` to its three channels, or nil for anything else.
    private static func parse(_ hex: String) -> (Double, Double, Double)? {
        guard hex.count == 7, hex.hasPrefix("#") else { return nil }
        let digits = hex.dropFirst()
        guard digits.allSatisfy(\.isHexDigit),
              let value = UInt32(digits, radix: 16) else { return nil }
        return (
            Double((value >> 16) & 0xFF) / 255,
            Double((value >> 8) & 0xFF) / 255,
            Double(value & 0xFF) / 255
        )
    }

    /// Hold relative luminance between 0.12 and 0.28, where a chip clears
    /// 3:1 against white and against the dark ground alike. The hue is
    /// kept; the channels are scaled in linear light.
    private static func clamped(_ rgb: (Double, Double, Double)) -> String {
        let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
        let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
        let lo = 0.12, hi = 0.28
        if y >= lo, y <= hi { return format(r, g, b) }
        let target = y < lo ? lo : hi
        guard y != 0 else { return format(target, target, target) }
        let k = target / y
        return format(min(1, r * k), min(1, g * k), min(1, b * k))
    }

    private static func linear(_ c: Double) -> Double {
        c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
    }

    private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
        func encode(_ v: Double) -> Int {
            let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
            return Int((s * 255).rounded())
        }
        return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
    }

    /// FNV-1a, 32-bit. The web hashes the name with it to index the
    /// palette, so this has to agree bit for bit.
    private static func fnv1a(_ text: String) -> UInt32 {
        var hash: UInt32 = 2_166_136_261
        for byte in text.utf8 {
            hash ^= UInt32(byte)
            hash &*= 16_777_619
        }
        return hash
    }
}

/// The chip colour for every label in one repository. Built from one
/// `label list` read; a label it has never heard of still derives a
/// colour from its name rather than falling back to grey.
nonisolated struct LabelColors: Sendable, Hashable {

    private let stored: [String: String]

    init(_ labels: [IssueLabel] = []) {
        stored = labels.reduce(into: [:]) { map, label in
            map[label.name] = label.color ?? ""
        }
    }

    func hex(_ name: String) -> String {
        LabelPalette.hex(name: name, stored: stored[name])
    }

    func color(_ name: String) -> Color {
        guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
            return .secondary
        }
        return Color(UIColor(hex: value))
    }
}

format runs on every stored colour, including ones already inside the window. Round-tripping through linear light and back is lossless to the nearest byte, which the #ff0000 and #0969da tests assert.

  • Step 4: Run the tests to verify they pass

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS, including every pre-existing suite.

  • Step 5: Commit
git add gitbay/Issues/IssueLabel.swift gitbayTests/LabelTests.swift
git commit -m "Label model and the web's chip colours"

Task 2: Colour the chips on the issue list and issue detail

Labels render as GBChip(label, .secondary) today — one flat colour, and only on the list. This task loads label list alongside the issues and paints both screens.

Files:

  • Modify: gitbay/Issues/IssueListViewModel.swift
  • Modify: gitbay/Issues/IssueDetailViewModel.swift
  • Modify: gitbay/Views/Issues/IssueListView.swift (:29 and :71-92)
  • Modify: gitbay/Views/Issues/IssueView.swift (labelFlow, called at :91 for labels and :109 for assignees)
  • Test: gitbayTests/LabelTests.swift

Interfaces:

  • Consumes: LabelColors, IssueLabel (Task 1); GitbayClient.readList(_:of:)
  • Produces:
    • IssueListViewModel.labels: [IssueLabel], IssueListViewModel.colors: LabelColors
    • IssueDetailViewModel.labels: [IssueLabel], IssueDetailViewModel.colors: LabelColors

Both view models keep the raw labels array as well as the derived colors. MR 2's label filter picks from labels; only the chips need colors.

The colour read is deliberately not fatal: a repository the caller can read issues in but not labels, or a label list that fails for any reason, leaves both properties at their empty defaults and every chip derives from its name. An issue list that refuses to load because its decoration failed would be a worse screen than one with derived colours.

Note: IssueListViewModel does not currently hold client — it passes it into PagedListModel and drops it (IssueListViewModel.swift:25-30). It needs a stored client now.

  • Step 1: Write the failing tests

Append to gitbayTests/LabelTests.swift:

private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
    let box = StubProtocol.box()
    let client = GitbayClient(
        instance: try GitbayInstance(url: "https://gitbay.org"),
        token: "test-token",
        session: box.session()
    )
    return (client, box)
}

private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
    return try #require(body["argv"] as? [String])
}

private let issuePageJSON = """
    {"protocol_version":1,"data":{"items":[\
    {"number":7,"title":"a bug","state":"open","author":"cmc",\
    "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
    """
private let labelListJSON = """
    {"protocol_version":1,"data":[\
    {"name":"bug","color":"#cf222e","issues":18},\
    {"name":"ci","issues":11}],"exit_code":0}
    """

@MainActor
struct IssueListColorsTests {

    @Test func loadsLabelsAlongsideTheIssues() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
        stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))

        let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
        await model.load()

        #expect(model.state.value?.count == 1)
        // The names are what MR 2's filter picker will offer.
        #expect(model.labels.map(\.name) == ["bug", "ci"])
        #expect(model.colors.hex("bug") == "#cf222e")
        #expect(model.colors.hex("ci") == "#1a7f37")
        #expect(stub.seen.contains {
            $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
        })
    }

    /// Decoration must not take the screen down with it.
    @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
        stub.enqueue(.init(status: 403, json: """
            {"protocol_version":1,"error":"denied","exit_code":4}
            """, match: "argv=label"))

        let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
        await model.load()

        #expect(model.state.value?.count == 1)
        #expect(model.labels.isEmpty)
        // Derived from the name, since nothing was stored.
        #expect(model.colors.hex("bug") == "#b93a86")
    }
}

@MainActor
struct IssueDetailColorsTests {

    @Test func loadsLabelsAlongsideTheIssue() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
            "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
            "exit_code":0}
            """, match: "argv=show"))
        stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))

        let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
        await model.load()

        #expect(model.state.value?.number == 7)
        #expect(model.colors.hex("bug") == "#cf222e")
    }
}
  • Step 2: Run the tests to verify they fail

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/IssueListColorsTests

Expected: build failure — value of type 'IssueListViewModel' has no member 'labels'.

  • Step 3: Add labels and colors to IssueListViewModel

In gitbay/Issues/IssueListViewModel.swift, store the client, add the two properties, and load the labels beside the issues:

    let list: PagedListModel<Issue>
    let repoPath: String
    private let client: GitbayClient

    /// This repository's labels, loaded beside the issues: the names for
    /// a picker, the colours for the chips. A failure here is not fatal —
    /// every chip then derives its colour from its name.
    private(set) var labels: [IssueLabel] = []
    private(set) var colors = LabelColors()

    init(client: GitbayClient, repoPath: String) {
        self.client = client
        self.repoPath = repoPath
        list = PagedListModel(
            client: client,
            argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
            emptyMessage: "No open issues."
        )
    }

    func load() async {
        async let issues: Void = list.reload()
        async let loaded = labelList()
        _ = await issues
        labels = await loaded
        colors = LabelColors(labels)
    }

    private func labelList() async -> [IssueLabel] {
        (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
    }
  • Step 4: Add the same to IssueDetailViewModel

It already holds client and repoPath (IssueDetailViewModel.swift:15-17), so only the properties and the load change:

    private(set) var labels: [IssueLabel] = []
    private(set) var colors = LabelColors()

    func load() async {
        async let loaded = labelList()
        do {
            state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
        } catch {
            state = .from(error)
        }
        labels = await loaded
        colors = LabelColors(labels)
    }

    private func labelList() async -> [IssueLabel] {
        (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
    }
  • Step 5: Run the tests to verify they pass

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS.

  • Step 6: Paint the chips on the issue list

In gitbay/Views/Issues/IssueListView.swift, pass the map into the row at :29:

                    IssueRow(issue: issue, colors: model.colors)

Add the property to IssueRow at :71:

private struct IssueRow: View {
    let issue: Issue
    let colors: LabelColors

And colour the chip at :89:

                ForEach(issue.labels ?? [], id: \.self) { label in
                    GBChip(label, colors.color(label))
                }
  • Step 7: Paint the chips on the issue detail

labelFlow in gitbay/Views/Issues/IssueView.swift renders both labels (:91) and assignees (:109), and only labels are coloured. Give it a colour closure defaulting to the current behaviour:

    private func labelFlow(
        _ items: [String],
        color: (String) -> Color = { _ in .secondary },
        remove: @escaping (String) -> Void
    ) -> some View {

Inside, replace the chip's .secondary with color(item). Then pass the map at the labels call site only:

            labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
                Task { await model.removeLabel(label) }
            })

Leave the assignees call site at :109 untouched — it takes the default.

  • Step 8: Build and run the whole suite

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS. Then launch the app and confirm the issue list and an issue's detail both show coloured chips against a real repository.

  • Step 9: Commit
git add gitbay/Issues/IssueListViewModel.swift gitbay/Issues/IssueDetailViewModel.swift \
        gitbay/Views/Issues/IssueListView.swift gitbay/Views/Issues/IssueView.swift \
        gitbayTests/LabelTests.swift
git commit -m "Paint label chips the colour the web paints them"

Task 3: The label management view model

Files:

  • Create: gitbay/Issues/LabelListViewModel.swift
  • Test: gitbayTests/LabelTests.swift

Interfaces:

  • Consumes: IssueLabel (Task 1), LoadState, GitbayClient.readList(_:of:), GitbayClient.run(_:stdin:)
  • Produces: LabelListViewModel with state: LoadState<[IssueLabel]>, actionError: String?, working: Bool, repoPath: String, load(), setLabel(_ name: String, color: String?), remove(_ name: String)

Follows MilestoneListViewModel for the load half and RepoSettingsViewModel.perform(argv:) (:89-101) for the write half, copied verbatim. It is eight lines, and the two existing copies are not this merge request's to refactor.

  • Step 1: Write the failing tests

Append to gitbayTests/LabelTests.swift:

@MainActor
struct LabelListViewModelTests {

    private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: labelListJSON))
        let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
        await model.load()
        return (model, stub)
    }

    @Test func listsLabelsWithTheirUseCounts() async throws {
        let (model, stub) = try await loaded()

        #expect(model.state.value?.map(\.name) == ["bug", "ci"])
        #expect(model.state.value?.map(\.issues) == [18, 11])
        #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
    }

    @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
        await model.load()

        guard case .empty = model.state else {
            Testing.Issue.record("expected an empty state, got \(model.state)")
            return
        }
    }

    @Test func settingAColourSendsTheColourFlag() async throws {
        let (model, stub) = try await loaded()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: labelListJSON))

        await model.setLabel("bug", color: "cf222e")

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write)
            == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
    }

    /// A label created without a colour must not send `--color`, which
    /// would be "clear it" rather than "leave whatever it has".
    @Test func creatingWithoutAColourOmitsTheFlag() async throws {
        let (model, stub) = try await loaded()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: labelListJSON))

        await model.setLabel("new", color: nil)

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
    }

    /// An empty string is not nil: it clears the colour.
    @Test func clearingSendsAnEmptyColour() async throws {
        let (model, stub) = try await loaded()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: labelListJSON))

        await model.setLabel("bug", color: "")

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
    }

    @Test func removingSendsTheRemoveCommand() async throws {
        let (model, stub) = try await loaded()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: labelListJSON))

        await model.remove("bug")

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
    }

    @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
        let (model, stub) = try await loaded()
        stub.enqueue(.init(status: 403, json: """
            {"protocol_version":1,"error":"write access required","exit_code":4}
            """))

        await model.remove("bug")

        #expect(model.actionError?.isEmpty == false)
        #expect(model.working == false)
    }
}

Testing.Issue.record is spelled with its module because this app has its own Issue model, and @testable import gitbay makes the app's win.

  • Step 2: Run the tests to verify they fail

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelListViewModelTests

Expected: build failure — cannot find 'LabelListViewModel' in scope.

  • Step 3: Write the implementation

Create gitbay/Issues/LabelListViewModel.swift:

import Foundation
import Observation

/// `label list <repo>`, plus the two commands that change the set:
/// `label set` creates one or recolours it, `label remove` takes it off
/// the repository and off every issue that carried it.
@Observable
@MainActor
final class LabelListViewModel {

    private(set) var state: LoadState<[IssueLabel]> = .loading
    private(set) var actionError: String?
    private(set) var working = false

    private let client: GitbayClient
    let repoPath: String

    init(client: GitbayClient, repoPath: String) {
        self.client = client
        self.repoPath = repoPath
    }

    func load() async {
        state = .loading
        do {
            let labels = try await client.readList(
                ["label", "list", repoPath], of: IssueLabel.self
            )
            state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
        } catch {
            state = .from(error)
        }
    }

    /// Create a label or set its colour. A nil colour omits `--color`
    /// entirely, which the server reads as "make sure it exists" and
    /// leaves any colour it already had; an empty string clears it.
    func setLabel(_ name: String, color: String?) async {
        var argv = ["label", "set", repoPath, name]
        if let color { argv.append(contentsOf: ["--color", color]) }
        await perform(argv)
    }

    func remove(_ name: String) async {
        await perform(["label", "remove", repoPath, name])
    }

    private func perform(_ argv: [String]) async {
        working = true
        actionError = nil
        defer { working = false }
        do {
            try await client.run(argv)
            await load()
        } catch let error as GitbayError {
            actionError = error.userFacingMessage
        } catch {
            actionError = GitbayError.transport(error).userFacingMessage
        }
    }
}
  • Step 4: Run the tests to verify they pass

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS.

  • Step 5: Commit
git add gitbay/Issues/LabelListViewModel.swift gitbayTests/LabelTests.swift
git commit -m "Label list view model: list, set colour, remove"

Task 4: The management screen and its route

Files:

  • Create: gitbay/Views/Issues/LabelListView.swift
  • Modify: gitbay/Views/Repos/RepoRoute.swift
  • Modify: gitbay/ContentView.swift (the RepoRoute switch, beside case .milestones at :99)
  • Modify: gitbay/Views/Repos/RepoView.swift (after the Milestones link at :43-45)

Interfaces:

  • Consumes: LabelListViewModel (Task 3), LabelPalette, LabelColors, IssueLabel (Task 1), LoadStateOverlay, GBChip, GBNotice
  • Produces: RepoRoute.labels(repo: String), LabelListView(client:repo:)

label remove takes the label off every issue that carried it, so removal is confirmed before it fires. Creating and recolouring are not.

  • Step 1: Add the route case

In gitbay/Views/Repos/RepoRoute.swift, after case milestones(repo: String):

    case labels(repo: String)
  • Step 2: Write the view

Create gitbay/Views/Issues/LabelListView.swift:

import SwiftUI

/// A repository's labels: their colour, how many issues carry each, and
/// the controls to create, recolour and remove them.
struct LabelListView: View {

    @State private var model: LabelListViewModel
    @State private var newLabel = ""
    @State private var pendingRemoval: IssueLabel?

    init(client: GitbayClient, repo: String) {
        _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo))
    }

    private var colors: LabelColors { LabelColors(model.state.value ?? []) }

    var body: some View {
        List {
            if let actionError = model.actionError {
                Section { GBNotice(actionError) }
            }

            Section("New label") {
                HStack {
                    TextField("Label", text: $newLabel)
                        .autocorrectionDisabled()
                        .textInputAutocapitalization(.never)
                    Button {
                        let name = newLabel.trimmingCharacters(in: .whitespaces)
                        newLabel = ""
                        Task { await model.setLabel(name, color: nil) }
                    } label: {
                        Image(systemName: "plus.circle.fill")
                    }
                    .disabled(
                        newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
                    )
                }
            }

            ForEach(model.state.value ?? []) { label in
                LabelRow(label: label, color: colors.color(label.name), working: model.working) {
                    hex in
                    Task { await model.setLabel(label.name, color: hex) }
                } remove: {
                    pendingRemoval = label
                }
            }
        }
        .overlay { LoadStateOverlay(state: model.state) }
        .navigationTitle("Labels")
        .navigationBarTitleDisplayMode(.inline)
        .task { await model.load() }
        .refreshable { await model.load() }
        .confirmationDialog(
            "Remove \(pendingRemoval?.name ?? "")?",
            isPresented: .init(
                get: { pendingRemoval != nil },
                set: { if !$0 { pendingRemoval = nil } }
            ),
            titleVisibility: .visible
        ) {
            Button("Remove", role: .destructive) {
                guard let label = pendingRemoval else { return }
                pendingRemoval = nil
                Task { await model.remove(label.name) }
            }
            Button("Cancel", role: .cancel) { pendingRemoval = nil }
        } message: {
            // The command takes the label off every issue, not just the set.
            Text("This takes the label off every issue that carries it.")
        }
    }
}

private struct LabelRow: View {
    let label: IssueLabel
    let color: Color
    let working: Bool
    let recolour: (String) -> Void
    let remove: () -> Void

    var body: some View {
        HStack(spacing: 8) {
            GBChip(label.name, color)
            Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
                .font(.gbSans(.caption))
                .foregroundStyle(.secondary)
            Spacer()
            Menu {
                ForEach(LabelPalette.palette, id: \.self) { hex in
                    Button(hex) { recolour(String(hex.dropFirst())) }
                }
                Button("Clear colour") { recolour("") }
                Button("Remove", role: .destructive) { remove() }
            } label: {
                Image(systemName: "ellipsis.circle")
            }
            .disabled(working)
        }
    }
}
  • Step 3: Wire the route

In gitbay/ContentView.swift, in the RepoRoute switch after case .milestones(let repo) at :99-100:

        case .labels(let repo):
            LabelListView(client: client, repo: repo)

In gitbay/Views/Repos/RepoView.swift, after the Milestones link at :43-45:

                    NavigationLink(value: RepoRoute.labels(repo: path)) {
                        Label("Labels", systemImage: "tag")
                    }

That Label is SwiftUI's, which is exactly why the model is called IssueLabel.

  • Step 4: Build and check the screen

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS. Then launch the app against a real repository and confirm: labels list with colours and counts; creating one adds it; recolouring repaints the chip; removing asks first and then removes.

  • Step 5: Commit
git add gitbay/Views/Issues/LabelListView.swift gitbay/Views/Repos/RepoRoute.swift \
        gitbay/ContentView.swift gitbay/Views/Repos/RepoView.swift
git commit -m "Label management screen"

Task 5: Flip the parity row and open the merge request

The wiki page is updated in the merge request that changes a row — that is what the page itself requires.

Files:

  • Modify: krz/gitbay, .gitbay/wiki/Parity.org, the Issues table

  • Step 1: Flip the row

In the Issues table, labels: list, colour becomes yes in the ios column. Touch no other row — the stale-row corrections are their own merge request on krz/gitbay, not this one.

The wiki lives at .gitbay/wiki/ on the default branch, so this is an edit to a file in the repository — a push or repo commit-file, not a wiki-specific command.

  • Step 2: Run the full suite one more time

Run: xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests

Expected: PASS. Record the actual count; do not claim a number you have not seen.

  • Step 3: Open the merge request
gitbay mr create --source labels-colour --target main \
  --title "Labels: list, colour, and a management screen" --file - <<'EOF'
`label list` reaches the app, so chips carry the colour the web paints
them: the stored `#rrggbb` when set, otherwise one picked from the same
eight-colour palette by an FNV-1a hash of the name, clamped to the same
luminance window. The issue list and issue detail both paint them.

A Labels screen under the repository creates a label, sets its colour and
removes one, dispatching `label set` and `label remove`. Removal is
confirmed first, since it takes the label off every issue.

Closes the `labels: list, colour` parity row.
EOF

Notes for whoever executes this

The colour tests are the ones that matter. Everything else here is scaffolding around a screen. If the golden values in Task 1 fail, do not adjust them to match the code — they were computed from the web's algorithm, and a mismatch means the port is wrong, not the expectation.

Label is taken. SwiftUI's Label is used 32 times in gitbay/. The model is IssueLabel for that reason; do not rename it back.

Issue is ambiguous in tests. This app has an Issue model and Swift Testing has an Issue type for recording failures. Under @testable import gitbay the app's wins, so spell Swift Testing's Testing.Issue.

label set without --color is not the same as with an empty one. Omitted means "make sure this label exists, keep any colour it has". --color '' clears it. The view model models this as nil vs "" and three tests pin the difference.

What MR 2 consumes from this. IssueListViewModel.labels is the array MR 2's filter picker offers, and IssueListViewModel.colors paints the chips inside it. Neither is private; do not narrow them.