Commit 82af2bbc90
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr01-labels.md added +1091
| @@ -0,0 +1,1091 @@ | ||
| 1 | # MR 1: Labels — list, colour, management Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Close the `labels: list, colour` parity row — paint every label chip | |
| 6 | the colour the web paints it, and add a screen that creates, recolours and | |
| 7 | removes labels. | |
| 8 | ||
| 9 | **Architecture:** An `IssueLabel` wire model plus a pure colour function ported | |
| 10 | from the web's `labelColors`/`clampChip`, so the same label reads the same on | |
| 11 | both 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 | |
| 13 | value, 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 | |
| 15 | colours. The management screen follows the existing milestone screen's | |
| 16 | model/view-model/view split exactly. | |
| 17 | ||
| 18 | **Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5, | |
| 19 | SwiftUI, 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 | |
| 49 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 50 | -destination 'platform=iOS Simulator,name=iPhone 17' \ | |
| 51 | test -only-testing:gitbayTests | |
| 52 | ``` | |
| 53 | ||
| 54 | To 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 | ||
| 79 | Pure logic, no network. This is the task worth the most care: the colours have | |
| 80 | to match the web's exactly, and the golden values below were computed from the | |
| 81 | web's own algorithm. | |
| 82 | ||
| 83 | **Background — what the web does** (`krz/gitbay`, | |
| 84 | `internal/httpd/web.go:1552-1568` and `1512-1545`): | |
| 85 | ||
| 86 | 1. If the stored colour matches `^#[0-9a-fA-F]{6}$`, use it. | |
| 87 | 2. Otherwise pick `labelPalette[fnv1a32(name) % 8]`. | |
| 88 | 3. 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 | ||
| 92 | Every palette entry already sits inside that window (Y ranges 0.1235 to | |
| 93 | 0.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 | |
| 98 | treated 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 | ||
| 116 | Create `gitbayTests/LabelTests.swift`: | |
| 117 | ||
| 118 | ```swift | |
| 119 | import Foundation | |
| 120 | import Testing | |
| 121 | @testable import gitbay | |
| 122 | ||
| 123 | struct 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 | ||
| 178 | struct 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 | ||
| 202 | struct 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 | ||
| 223 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelPaletteTests` | |
| 224 | ||
| 225 | Expected: the build fails — `cannot find 'LabelPalette' in scope`, `cannot find | |
| 226 | type 'IssueLabel' in scope`, `cannot find 'LabelColors' in scope`. | |
| 227 | ||
| 228 | - [ ] **Step 3: Write the implementation** | |
| 229 | ||
| 230 | Create `gitbay/Issues/IssueLabel.swift`: | |
| 231 | ||
| 232 | ```swift | |
| 233 | import 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. | |
| 240 | nonisolated 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. | |
| 253 | nonisolated 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. | |
| 326 | nonisolated 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. | |
| 350 | Round-tripping through linear light and back is lossless to the nearest byte, | |
| 351 | which the `#ff0000` and `#0969da` tests assert. | |
| 352 | ||
| 353 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 354 | ||
| 355 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 356 | ||
| 357 | Expected: PASS, including every pre-existing suite. | |
| 358 | ||
| 359 | - [ ] **Step 5: Commit** | |
| 360 | ||
| 361 | ```bash | |
| 362 | git add gitbay/Issues/IssueLabel.swift gitbayTests/LabelTests.swift | |
| 363 | git 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 | ||
| 370 | Labels render as `GBChip(label, .secondary)` today — one flat colour, and only | |
| 371 | on the list. This task loads `label list` alongside the issues and paints both | |
| 372 | screens. | |
| 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 | ||
| 388 | Both view models keep the raw `labels` array as well as the derived `colors`. | |
| 389 | MR 2's label filter picks from `labels`; only the chips need `colors`. | |
| 390 | ||
| 391 | The colour read is deliberately not fatal: a repository the caller can read | |
| 392 | issues in but not labels, or a `label list` that fails for any reason, leaves | |
| 393 | both properties at their empty defaults and every chip derives from its name. | |
| 394 | An issue list that refuses to load because its decoration failed would be a | |
| 395 | worse screen than one with derived colours. | |
| 396 | ||
| 397 | **Note:** `IssueListViewModel` does not currently hold `client` — it passes it | |
| 398 | into `PagedListModel` and drops it (`IssueListViewModel.swift:25-30`). It needs | |
| 399 | a stored `client` now. | |
| 400 | ||
| 401 | - [ ] **Step 1: Write the failing tests** | |
| 402 | ||
| 403 | Append to `gitbayTests/LabelTests.swift`: | |
| 404 | ||
| 405 | ```swift | |
| 406 | private 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 | ||
| 416 | private 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 | ||
| 421 | private 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 | """ | |
| 426 | private 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 | |
| 433 | struct 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 | |
| 472 | struct 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 | ||
| 494 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/IssueListColorsTests` | |
| 495 | ||
| 496 | Expected: build failure — `value of type 'IssueListViewModel' has no member 'labels'`. | |
| 497 | ||
| 498 | - [ ] **Step 3: Add `labels` and `colors` to `IssueListViewModel`** | |
| 499 | ||
| 500 | In `gitbay/Issues/IssueListViewModel.swift`, store the client, add the two | |
| 501 | properties, 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 | ||
| 539 | It already holds `client` and `repoPath` (`IssueDetailViewModel.swift:15-17`), | |
| 540 | so 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 | ||
| 564 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 565 | ||
| 566 | Expected: PASS. | |
| 567 | ||
| 568 | - [ ] **Step 6: Paint the chips on the issue list** | |
| 569 | ||
| 570 | In `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 | ||
| 576 | Add the property to `IssueRow` at `:71`: | |
| 577 | ||
| 578 | ```swift | |
| 579 | private struct IssueRow: View { | |
| 580 | let issue: Issue | |
| 581 | let colors: LabelColors | |
| 582 | ``` | |
| 583 | ||
| 584 | And 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`) | |
| 595 | and assignees (`:109`), and only labels are coloured. Give it a colour closure | |
| 596 | defaulting 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 | ||
| 606 | Inside, replace the chip's `.secondary` with `color(item)`. Then pass the map at | |
| 607 | the 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 | ||
| 615 | Leave the assignees call site at `:109` untouched — it takes the default. | |
| 616 | ||
| 617 | - [ ] **Step 8: Build and run the whole suite** | |
| 618 | ||
| 619 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 620 | ||
| 621 | Expected: PASS. Then launch the app and confirm the issue list and an issue's | |
| 622 | detail both show coloured chips against a real repository. | |
| 623 | ||
| 624 | - [ ] **Step 9: Commit** | |
| 625 | ||
| 626 | ```bash | |
| 627 | git add gitbay/Issues/IssueListViewModel.swift gitbay/Issues/IssueDetailViewModel.swift \ | |
| 628 | gitbay/Views/Issues/IssueListView.swift gitbay/Views/Issues/IssueView.swift \ | |
| 629 | gitbayTests/LabelTests.swift | |
| 630 | git 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 | ||
| 648 | Follows `MilestoneListViewModel` for the load half and | |
| 649 | `RepoSettingsViewModel.perform(argv:)` (`:89-101`) for the write half, copied | |
| 650 | verbatim. It is eight lines, and the two existing copies are not this merge | |
| 651 | request's to refactor. | |
| 652 | ||
| 653 | - [ ] **Step 1: Write the failing tests** | |
| 654 | ||
| 655 | Append to `gitbayTests/LabelTests.swift`: | |
| 656 | ||
| 657 | ```swift | |
| 658 | @MainActor | |
| 659 | struct 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 | ||
| 766 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelListViewModelTests` | |
| 767 | ||
| 768 | Expected: build failure — `cannot find 'LabelListViewModel' in scope`. | |
| 769 | ||
| 770 | - [ ] **Step 3: Write the implementation** | |
| 771 | ||
| 772 | Create `gitbay/Issues/LabelListViewModel.swift`: | |
| 773 | ||
| 774 | ```swift | |
| 775 | import Foundation | |
| 776 | import 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 | |
| 783 | final 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 | ||
| 840 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 841 | ||
| 842 | Expected: PASS. | |
| 843 | ||
| 844 | - [ ] **Step 5: Commit** | |
| 845 | ||
| 846 | ```bash | |
| 847 | git add gitbay/Issues/LabelListViewModel.swift gitbayTests/LabelTests.swift | |
| 848 | git 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 | |
| 868 | confirmed before it fires. Creating and recolouring are not. | |
| 869 | ||
| 870 | - [ ] **Step 1: Add the route case** | |
| 871 | ||
| 872 | In `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 | ||
| 880 | Create `gitbay/Views/Issues/LabelListView.swift`: | |
| 881 | ||
| 882 | ```swift | |
| 883 | import SwiftUI | |
| 884 | ||
| 885 | /// A repository's labels: their colour, how many issues carry each, and | |
| 886 | /// the controls to create, recolour and remove them. | |
| 887 | struct 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 | ||
| 958 | private 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 | ||
| 989 | In `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 | ||
| 997 | In `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 | ||
| 1005 | That `Label` is SwiftUI's, which is exactly why the model is called | |
| 1006 | `IssueLabel`. | |
| 1007 | ||
| 1008 | - [ ] **Step 4: Build and check the screen** | |
| 1009 | ||
| 1010 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 1011 | ||
| 1012 | Expected: PASS. Then launch the app against a real repository and confirm: | |
| 1013 | labels list with colours and counts; creating one adds it; recolouring repaints | |
| 1014 | the chip; removing asks first and then removes. | |
| 1015 | ||
| 1016 | - [ ] **Step 5: Commit** | |
| 1017 | ||
| 1018 | ```bash | |
| 1019 | git add gitbay/Views/Issues/LabelListView.swift gitbay/Views/Repos/RepoRoute.swift \ | |
| 1020 | gitbay/ContentView.swift gitbay/Views/Repos/RepoView.swift | |
| 1021 | git commit -m "Label management screen" | |
| 1022 | ``` | |
| 1023 | ||
| 1024 | --- | |
| 1025 | ||
| 1026 | ### Task 5: Flip the parity row and open the merge request | |
| 1027 | ||
| 1028 | The wiki page is updated in the merge request that changes a row — that is what | |
| 1029 | the 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 | ||
| 1036 | In the Issues table, `labels: list, colour` becomes `yes` in the `ios` column. | |
| 1037 | Touch no other row — the stale-row corrections are their own merge request on | |
| 1038 | `krz/gitbay`, not this one. | |
| 1039 | ||
| 1040 | The wiki lives at `.gitbay/wiki/` on the default branch, so this is an edit to a | |
| 1041 | file in the repository — a push or `repo commit-file`, not a wiki-specific | |
| 1042 | command. | |
| 1043 | ||
| 1044 | - [ ] **Step 2: Run the full suite one more time** | |
| 1045 | ||
| 1046 | Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests` | |
| 1047 | ||
| 1048 | Expected: 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 | |
| 1053 | gitbay 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 | |
| 1056 | them: the stored `#rrggbb` when set, otherwise one picked from the same | |
| 1057 | eight-colour palette by an FNV-1a hash of the name, clamped to the same | |
| 1058 | luminance window. The issue list and issue detail both paint them. | |
| 1059 | ||
| 1060 | A Labels screen under the repository creates a label, sets its colour and | |
| 1061 | removes one, dispatching `label set` and `label remove`. Removal is | |
| 1062 | confirmed first, since it takes the label off every issue. | |
| 1063 | ||
| 1064 | Closes the `labels: list, colour` parity row. | |
| 1065 | EOF | |
| 1066 | ``` | |
| 1067 | ||
| 1068 | --- | |
| 1069 | ||
| 1070 | ## Notes for whoever executes this | |
| 1071 | ||
| 1072 | **The colour tests are the ones that matter.** Everything else here is | |
| 1073 | scaffolding around a screen. If the golden values in Task 1 fail, do not adjust | |
| 1074 | them to match the code — they were computed from the web's algorithm, and a | |
| 1075 | mismatch means the port is wrong, not the expectation. | |
| 1076 | ||
| 1077 | **`Label` is taken.** SwiftUI's `Label` is used 32 times in `gitbay/`. The model | |
| 1078 | is `IssueLabel` for that reason; do not rename it back. | |
| 1079 | ||
| 1080 | **`Issue` is ambiguous in tests.** This app has an `Issue` model and Swift | |
| 1081 | Testing has an `Issue` type for recording failures. Under `@testable import | |
| 1082 | gitbay` 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 | |
| 1085 | means "make sure this label exists, keep any colour it has". `--color ''` clears | |
| 1086 | it. The view model models this as `nil` vs `""` and three tests pin the | |
| 1087 | difference. | |
| 1088 | ||
| 1089 | **What MR 2 consumes from this.** `IssueListViewModel.labels` is the array MR | |
| 1090 | 2's filter picker offers, and `IssueListViewModel.colors` paints the chips | |
| 1091 | inside it. Neither is private; do not narrow them. | |