docs/superpowers/plans/2026-09-06-mr01-labels.md
1091 lines · 40663 bytes
9 symbols in this file
MR 1: Labels — list, colour, management Implementation PlanGlobal ConstraintsFile StructureTask 1: Label model and colour derivationTask 2: Colour the chips on the issue list and issue detailTask 3: The label management view modelTask 4: The management screen and its routeTask 5: Flip the parity row and open the merge requestNotes for whoever executes this
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
MainActorisolation. Wire models arenonisolated structs. - Swift Testing (
import Testing,@Test,#expect,#require) — never XCTest. gitbayTestsis hermetic and runs offline. Network goes throughStubProtocol; never add a test that reaches the network.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit — both arePBXFileSystemSynchronizedRootGroups (gitbay.xcodeproj/project.pbxproj:39-48), so Xcode picks them up from the filesystem. - The model is
IssueLabel, neverLabel. SwiftUI'sLabelis used 32 times acrossgitbay/. A type namedLabelin 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-Bytrailer. - 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):
- If the stored colour matches
^#[0-9a-fA-F]{6}$, use it. - Otherwise pick
labelPalette[fnv1a32(name) % 8]. - 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, Identifiablewithname: String,color: String?,issues: Int,id: String { name }enum LabelPalettewithstatic let palette: [String]andstatic func hex(name: String, stored: String?) -> Stringstruct LabelColorswithinit(_ 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(:29and:71-92) - Modify:
gitbay/Views/Issues/IssueView.swift(labelFlow, called at:91for labels and:109for assignees) - Test:
gitbayTests/LabelTests.swift
Interfaces:
- Consumes:
LabelColors,IssueLabel(Task 1);GitbayClient.readList(_:of:) - Produces:
IssueListViewModel.labels: [IssueLabel],IssueListViewModel.colors: LabelColorsIssueDetailViewModel.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
labelsandcolorstoIssueListViewModel
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:
LabelListViewModelwithstate: 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(theRepoRouteswitch, besidecase .milestonesat: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.