Commit 62763741b4

62763741b429bcb6b7be29fa37bf2c48300d9692

parent: 084e9e22b1

Unsigned

cmc <hello@cleberg.net> · 2026-09-06 18:42 UTC

Labels: list, colour, and a management screen (!47)

`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, held 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 that carries it.

The label read is decoration: it is fetched once per screen, skipped when
the primary read failed, and a failure leaves every chip deriving its
colour from its name rather than taking the screen down.

Also carries the spec and implementation plan for the wider iOS parity
work under `docs/superpowers/`.

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

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr01-labels.md added +1091
@@ -0,0 +1,1091 @@
1# MR 1: Labels — list, colour, management Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the `labels: list, colour` parity row — paint every label chip
6the colour the web paints it, and add a screen that creates, recolours and
7removes labels.
8
9**Architecture:** An `IssueLabel` wire model plus a pure colour function ported
10from the web's `labelColors`/`clampChip`, so the same label reads the same on
11both surfaces. Chip colours reach the issue list and issue detail through a
12`LabelColors` value type each view model builds from one `label list` read — a
13value, not a shared reference. Each view model also keeps the raw
14`[IssueLabel]`, because MR 2's filter picker needs the names, not just the
15colours. The management screen follows the existing milestone screen's
16model/view-model/view split exactly.
17
18**Tech Stack:** Swift 6 language mode, default `MainActor` isolation, iOS 26.5,
19SwiftUI, Swift Testing (not XCTest), `@Observable` view models.
20
21**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
22
23## Global Constraints
24
25- Swift 6 language mode, default `MainActor` isolation. Wire models are
26 `nonisolated struct`s.
27- Swift Testing (`import Testing`, `@Test`, `#expect`, `#require`) — never
28 XCTest.
29- `gitbayTests` is hermetic and runs offline. Network goes through
30 `StubProtocol`; never add a test that reaches the network.
31- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj`
32 edit — both are `PBXFileSystemSynchronizedRootGroup`s
33 (`gitbay.xcodeproj/project.pbxproj:39-48`), so Xcode picks them up from the
34 filesystem.
35- **The model is `IssueLabel`, never `Label`.** SwiftUI's `Label` is used 32
36 times across `gitbay/`. A type named `Label` in this module would shadow it
37 everywhere and break all of them.
38- The app adds no capability the CLI lacks. Every command here already exists:
39 `label list <owner/name>`, `label set <owner/name> <label> [--color rrggbb|'']`,
40 `label remove <owner/name> <label>`.
41- Never mention Claude, LLMs or AI in commits, code comments, or the merge
42 request. No `Co-Authored-By` trailer.
43- Never commit to `main`. This work happens on a branch and lands as a merge
44 request.
45
46**Build and test command** (used by every "run the tests" step):
47
48```bash
49xcodebuild -project gitbay.xcodeproj -scheme gitbay \
50 -destination 'platform=iOS Simulator,name=iPhone 17' \
51 test -only-testing:gitbayTests
52```
53
54To run one suite, append the suite name:
55`-only-testing:gitbayTests/LabelPaletteTests`.
56
57---
58
59## File Structure
60
61| File | Responsibility |
62|------|----------------|
63| `gitbay/Issues/IssueLabel.swift` (create) | `IssueLabel` wire model, `LabelPalette` colour derivation, `LabelColors` lookup |
64| `gitbay/Issues/LabelListViewModel.swift` (create) | Load, create/recolour, remove — the management screen's state |
65| `gitbay/Views/Issues/LabelListView.swift` (create) | The management screen |
66| `gitbayTests/LabelTests.swift` (create) | Every test in this plan |
67| `gitbay/Views/Repos/RepoRoute.swift` (modify) | `labels(repo:)` case |
68| `gitbay/ContentView.swift` (modify) | Route to `LabelListView` |
69| `gitbay/Views/Repos/RepoView.swift` (modify) | Entry point beside Milestones |
70| `gitbay/Issues/IssueListViewModel.swift` (modify) | Hold `client`, expose `labels` and `colors` |
71| `gitbay/Issues/IssueDetailViewModel.swift` (modify) | Expose `labels` and `colors` |
72| `gitbay/Views/Issues/IssueListView.swift` (modify) | Coloured chips |
73| `gitbay/Views/Issues/IssueView.swift` (modify) | Coloured chips |
74
75---
76
77### Task 1: Label model and colour derivation
78
79Pure logic, no network. This is the task worth the most care: the colours have
80to match the web's exactly, and the golden values below were computed from the
81web's own algorithm.
82
83**Background — what the web does** (`krz/gitbay`,
84`internal/httpd/web.go:1552-1568` and `1512-1545`):
85
861. If the stored colour matches `^#[0-9a-fA-F]{6}$`, use it.
872. Otherwise pick `labelPalette[fnv1a32(name) % 8]`.
883. Either way, run it through `clampChip`, which holds relative luminance
89 between 0.12 and 0.28 by scaling the channels in linear light, keeping the
90 hue.
91
92Every palette entry already sits inside that window (Y ranges 0.1235 to
930.1862), so a *derived* colour is never adjusted. Clamping only ever changes a
94*stored* colour. Implement it that way and the palette path stays a plain index.
95
96`label set` normalises what it stores to lowercase with a leading `#`
97(`internal/control/label.go:66`), so a stored value in any other shape is
98treated as unset.
99
100**Files:**
101- Create: `gitbay/Issues/IssueLabel.swift`
102- Test: `gitbayTests/LabelTests.swift`
103
104**Interfaces:**
105- Consumes: `UIColor.init(hex: UInt32)` (`gitbay/Theme/Theme.swift:44`)
106- Produces:
107 - `struct IssueLabel: Decodable, Sendable, Hashable, Identifiable` with
108 `name: String`, `color: String?`, `issues: Int`, `id: String { name }`
109 - `enum LabelPalette` with `static let palette: [String]` and
110 `static func hex(name: String, stored: String?) -> String`
111 - `struct LabelColors` with `init(_ labels: [IssueLabel] = [])`,
112 `func hex(_ name: String) -> String`, `func color(_ name: String) -> Color`
113
114- [ ] **Step 1: Write the failing tests**
115
116Create `gitbayTests/LabelTests.swift`:
117
118```swift
119import Foundation
120import Testing
121@testable import gitbay
122
123struct LabelPaletteTests {
124
125 // Golden values computed from the web's own algorithm: FNV-1a 32 of
126 // the name, modulo the eight-colour palette. If these drift, the two
127 // surfaces paint the same label differently.
128 @Test func derivesAStableColourFromTheName() {
129 #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
130 #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
131 #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
132 #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
133 #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
134 }
135
136 @Test func theSameNameAlwaysDerivesTheSameColour() {
137 #expect(LabelPalette.hex(name: "bug", stored: nil)
138 == LabelPalette.hex(name: "bug", stored: nil))
139 }
140
141 /// A stored colour already inside the luminance window is used as-is.
142 @Test func keepsAStoredColourThatIsAlreadyLegible() {
143 #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
144 #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
145 }
146
147 /// Too dark or too bright is scaled back into the window, so a chip
148 /// stays readable as text on both grounds.
149 @Test func clampsAStoredColourOutsideTheWindow() {
150 #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
151 #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
152 }
153
154 @Test func upperCaseStoredColoursNormaliseDown() {
155 #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
156 }
157
158 /// `label set` stores `#rrggbb`. Anything else — an empty string, a
159 /// bare six digits, a name — never reached the store as a colour, so
160 /// it is treated as unset and the name derives one.
161 @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
162 let derived = LabelPalette.hex(name: "bug", stored: nil)
163 #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
164 #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
165 #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
166 #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
167 }
168
169 @Test func everyPaletteEntryIsAlreadyLegible() {
170 // No palette colour is ever clamped, so a derived colour is a
171 // plain lookup. If this fails the palette drifted from the web's.
172 for hex in LabelPalette.palette {
173 #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
174 }
175 }
176}
177
178struct IssueLabelDecodingTests {
179
180 @Test func decodesAListRow() throws {
181 let json = Data("""
182 [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
183 """.utf8)
184 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
185
186 #expect(labels.count == 2)
187 #expect(labels[0].name == "bug")
188 #expect(labels[0].color == "#cf222e")
189 #expect(labels[0].issues == 18)
190 // `color` is omitempty on the wire — absent, not null, when unset.
191 #expect(labels[1].color == nil)
192 #expect(labels[1].issues == 11)
193 }
194
195 @Test func theIdIsTheName() throws {
196 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
197 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
198 #expect(labels[0].id == "bug")
199 }
200}
201
202struct LabelColorsTests {
203
204 @Test func looksUpAStoredColour() {
205 let colors = LabelColors([
206 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
207 IssueLabel(name: "ci", color: nil, issues: 2),
208 ])
209 #expect(colors.hex("bug") == "#cf222e")
210 #expect(colors.hex("ci") == "#1a7f37")
211 }
212
213 /// An issue can carry a label the map has not loaded — a stale list, or
214 /// a label added since. It still derives a colour rather than going grey.
215 @Test func anUnknownLabelStillDerivesItsColour() {
216 #expect(LabelColors().hex("docs") == "#9a6700")
217 }
218}
219```
220
221- [ ] **Step 2: Run the tests to verify they fail**
222
223Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelPaletteTests`
224
225Expected: the build fails — `cannot find 'LabelPalette' in scope`, `cannot find
226type 'IssueLabel' in scope`, `cannot find 'LabelColors' in scope`.
227
228- [ ] **Step 3: Write the implementation**
229
230Create `gitbay/Issues/IssueLabel.swift`:
231
232```swift
233import SwiftUI
234
235/// One row of `label list`: the label, the colour it was set to, and how
236/// many issues carry it.
237///
238/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
239/// used throughout this module and a bare `Label` here would shadow it.
240nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
241 let name: String
242 /// `#rrggbb` when set, absent otherwise — `label set` normalises to
243 /// lowercase with the hash, so any other shape never reached the store.
244 let color: String?
245 let issues: Int
246
247 var id: String { name }
248}
249
250/// Chip colours for labels, matching what the web paints
251/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
252/// label reads the same on both surfaces.
253nonisolated enum LabelPalette {
254
255 /// Mid-tone hues that stay legible as text on both grounds. Every
256 /// entry already sits inside the window `clamped` holds, so a derived
257 /// colour is a plain lookup and only a stored one is ever adjusted.
258 static let palette = [
259 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
260 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
261 ]
262
263 /// The colour the web would paint for this label: the stored one when
264 /// it is a hex colour, otherwise one picked from the palette by name.
265 static func hex(name: String, stored: String?) -> String {
266 guard let stored, let parsed = parse(stored) else {
267 return palette[Int(fnv1a(name) % UInt32(palette.count))]
268 }
269 return clamped(parsed)
270 }
271
272 /// `#rrggbb` to its three channels, or nil for anything else.
273 private static func parse(_ hex: String) -> (Double, Double, Double)? {
274 guard hex.count == 7, hex.hasPrefix("#") else { return nil }
275 let digits = hex.dropFirst()
276 guard digits.allSatisfy(\.isHexDigit),
277 let value = UInt32(digits, radix: 16) else { return nil }
278 return (
279 Double((value >> 16) & 0xFF) / 255,
280 Double((value >> 8) & 0xFF) / 255,
281 Double(value & 0xFF) / 255
282 )
283 }
284
285 /// Hold relative luminance between 0.12 and 0.28, where a chip clears
286 /// 3:1 against white and against the dark ground alike. The hue is
287 /// kept; the channels are scaled in linear light.
288 private static func clamped(_ rgb: (Double, Double, Double)) -> String {
289 let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
290 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
291 let lo = 0.12, hi = 0.28
292 if y >= lo, y <= hi { return format(r, g, b) }
293 let target = y < lo ? lo : hi
294 guard y != 0 else { return format(target, target, target) }
295 let k = target / y
296 return format(min(1, r * k), min(1, g * k), min(1, b * k))
297 }
298
299 private static func linear(_ c: Double) -> Double {
300 c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
301 }
302
303 private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
304 func encode(_ v: Double) -> Int {
305 let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
306 return Int((s * 255).rounded())
307 }
308 return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
309 }
310
311 /// FNV-1a, 32-bit. The web hashes the name with it to index the
312 /// palette, so this has to agree bit for bit.
313 private static func fnv1a(_ text: String) -> UInt32 {
314 var hash: UInt32 = 2_166_136_261
315 for byte in text.utf8 {
316 hash ^= UInt32(byte)
317 hash &*= 16_777_619
318 }
319 return hash
320 }
321}
322
323/// The chip colour for every label in one repository. Built from one
324/// `label list` read; a label it has never heard of still derives a
325/// colour from its name rather than falling back to grey.
326nonisolated struct LabelColors: Sendable, Hashable {
327
328 private let stored: [String: String]
329
330 init(_ labels: [IssueLabel] = []) {
331 stored = labels.reduce(into: [:]) { map, label in
332 map[label.name] = label.color ?? ""
333 }
334 }
335
336 func hex(_ name: String) -> String {
337 LabelPalette.hex(name: name, stored: stored[name])
338 }
339
340 func color(_ name: String) -> Color {
341 guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
342 return .secondary
343 }
344 return Color(UIColor(hex: value))
345 }
346}
347```
348
349`format` runs on every stored colour, including ones already inside the window.
350Round-tripping through linear light and back is lossless to the nearest byte,
351which the `#ff0000` and `#0969da` tests assert.
352
353- [ ] **Step 4: Run the tests to verify they pass**
354
355Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
356
357Expected: PASS, including every pre-existing suite.
358
359- [ ] **Step 5: Commit**
360
361```bash
362git add gitbay/Issues/IssueLabel.swift gitbayTests/LabelTests.swift
363git commit -m "Label model and the web's chip colours"
364```
365
366---
367
368### Task 2: Colour the chips on the issue list and issue detail
369
370Labels render as `GBChip(label, .secondary)` today — one flat colour, and only
371on the list. This task loads `label list` alongside the issues and paints both
372screens.
373
374**Files:**
375- Modify: `gitbay/Issues/IssueListViewModel.swift`
376- Modify: `gitbay/Issues/IssueDetailViewModel.swift`
377- Modify: `gitbay/Views/Issues/IssueListView.swift` (`:29` and `:71-92`)
378- Modify: `gitbay/Views/Issues/IssueView.swift` (`labelFlow`, called at `:91`
379 for labels and `:109` for assignees)
380- Test: `gitbayTests/LabelTests.swift`
381
382**Interfaces:**
383- Consumes: `LabelColors`, `IssueLabel` (Task 1); `GitbayClient.readList(_:of:)`
384- Produces:
385 - `IssueListViewModel.labels: [IssueLabel]`, `IssueListViewModel.colors: LabelColors`
386 - `IssueDetailViewModel.labels: [IssueLabel]`, `IssueDetailViewModel.colors: LabelColors`
387
388Both view models keep the raw `labels` array as well as the derived `colors`.
389MR 2's label filter picks from `labels`; only the chips need `colors`.
390
391The colour read is deliberately not fatal: a repository the caller can read
392issues in but not labels, or a `label list` that fails for any reason, leaves
393both properties at their empty defaults and every chip derives from its name.
394An issue list that refuses to load because its decoration failed would be a
395worse screen than one with derived colours.
396
397**Note:** `IssueListViewModel` does not currently hold `client` — it passes it
398into `PagedListModel` and drops it (`IssueListViewModel.swift:25-30`). It needs
399a stored `client` now.
400
401- [ ] **Step 1: Write the failing tests**
402
403Append to `gitbayTests/LabelTests.swift`:
404
405```swift
406private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
407 let box = StubProtocol.box()
408 let client = GitbayClient(
409 instance: try GitbayInstance(url: "https://gitbay.org"),
410 token: "test-token",
411 session: box.session()
412 )
413 return (client, box)
414}
415
416private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
417 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
418 return try #require(body["argv"] as? [String])
419}
420
421private let issuePageJSON = """
422 {"protocol_version":1,"data":{"items":[\
423 {"number":7,"title":"a bug","state":"open","author":"cmc",\
424 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
425 """
426private let labelListJSON = """
427 {"protocol_version":1,"data":[\
428 {"name":"bug","color":"#cf222e","issues":18},\
429 {"name":"ci","issues":11}],"exit_code":0}
430 """
431
432@MainActor
433struct IssueListColorsTests {
434
435 @Test func loadsLabelsAlongsideTheIssues() async throws {
436 let (client, stub) = try makeClient()
437 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
438 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
439
440 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
441 await model.load()
442
443 #expect(model.state.value?.count == 1)
444 // The names are what MR 2's filter picker will offer.
445 #expect(model.labels.map(\.name) == ["bug", "ci"])
446 #expect(model.colors.hex("bug") == "#cf222e")
447 #expect(model.colors.hex("ci") == "#1a7f37")
448 #expect(stub.seen.contains {
449 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
450 })
451 }
452
453 /// Decoration must not take the screen down with it.
454 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
455 let (client, stub) = try makeClient()
456 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
457 stub.enqueue(.init(status: 403, json: """
458 {"protocol_version":1,"error":"denied","exit_code":4}
459 """, match: "argv=label"))
460
461 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
462 await model.load()
463
464 #expect(model.state.value?.count == 1)
465 #expect(model.labels.isEmpty)
466 // Derived from the name, since nothing was stored.
467 #expect(model.colors.hex("bug") == "#b93a86")
468 }
469}
470
471@MainActor
472struct IssueDetailColorsTests {
473
474 @Test func loadsLabelsAlongsideTheIssue() async throws {
475 let (client, stub) = try makeClient()
476 stub.enqueue(.init(status: 200, json: """
477 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
478 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
479 "exit_code":0}
480 """, match: "argv=show"))
481 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
482
483 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
484 await model.load()
485
486 #expect(model.state.value?.number == 7)
487 #expect(model.colors.hex("bug") == "#cf222e")
488 }
489}
490```
491
492- [ ] **Step 2: Run the tests to verify they fail**
493
494Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/IssueListColorsTests`
495
496Expected: build failure — `value of type 'IssueListViewModel' has no member 'labels'`.
497
498- [ ] **Step 3: Add `labels` and `colors` to `IssueListViewModel`**
499
500In `gitbay/Issues/IssueListViewModel.swift`, store the client, add the two
501properties, and load the labels beside the issues:
502
503```swift
504 let list: PagedListModel<Issue>
505 let repoPath: String
506 private let client: GitbayClient
507
508 /// This repository's labels, loaded beside the issues: the names for
509 /// a picker, the colours for the chips. A failure here is not fatal —
510 /// every chip then derives its colour from its name.
511 private(set) var labels: [IssueLabel] = []
512 private(set) var colors = LabelColors()
513
514 init(client: GitbayClient, repoPath: String) {
515 self.client = client
516 self.repoPath = repoPath
517 list = PagedListModel(
518 client: client,
519 argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue],
520 emptyMessage: "No open issues."
521 )
522 }
523
524 func load() async {
525 async let issues: Void = list.reload()
526 async let loaded = labelList()
527 _ = await issues
528 labels = await loaded
529 colors = LabelColors(labels)
530 }
531
532 private func labelList() async -> [IssueLabel] {
533 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
534 }
535```
536
537- [ ] **Step 4: Add the same to `IssueDetailViewModel`**
538
539It already holds `client` and `repoPath` (`IssueDetailViewModel.swift:15-17`),
540so only the properties and the load change:
541
542```swift
543 private(set) var labels: [IssueLabel] = []
544 private(set) var colors = LabelColors()
545
546 func load() async {
547 async let loaded = labelList()
548 do {
549 state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
550 } catch {
551 state = .from(error)
552 }
553 labels = await loaded
554 colors = LabelColors(labels)
555 }
556
557 private func labelList() async -> [IssueLabel] {
558 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
559 }
560```
561
562- [ ] **Step 5: Run the tests to verify they pass**
563
564Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
565
566Expected: PASS.
567
568- [ ] **Step 6: Paint the chips on the issue list**
569
570In `gitbay/Views/Issues/IssueListView.swift`, pass the map into the row at `:29`:
571
572```swift
573 IssueRow(issue: issue, colors: model.colors)
574```
575
576Add the property to `IssueRow` at `:71`:
577
578```swift
579private struct IssueRow: View {
580 let issue: Issue
581 let colors: LabelColors
582```
583
584And colour the chip at `:89`:
585
586```swift
587 ForEach(issue.labels ?? [], id: \.self) { label in
588 GBChip(label, colors.color(label))
589 }
590```
591
592- [ ] **Step 7: Paint the chips on the issue detail**
593
594`labelFlow` in `gitbay/Views/Issues/IssueView.swift` renders both labels (`:91`)
595and assignees (`:109`), and only labels are coloured. Give it a colour closure
596defaulting to the current behaviour:
597
598```swift
599 private func labelFlow(
600 _ items: [String],
601 color: (String) -> Color = { _ in .secondary },
602 remove: @escaping (String) -> Void
603 ) -> some View {
604```
605
606Inside, replace the chip's `.secondary` with `color(item)`. Then pass the map at
607the labels call site only:
608
609```swift
610 labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
611 Task { await model.removeLabel(label) }
612 })
613```
614
615Leave the assignees call site at `:109` untouched — it takes the default.
616
617- [ ] **Step 8: Build and run the whole suite**
618
619Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
620
621Expected: PASS. Then launch the app and confirm the issue list and an issue's
622detail both show coloured chips against a real repository.
623
624- [ ] **Step 9: Commit**
625
626```bash
627git add gitbay/Issues/IssueListViewModel.swift gitbay/Issues/IssueDetailViewModel.swift \
628 gitbay/Views/Issues/IssueListView.swift gitbay/Views/Issues/IssueView.swift \
629 gitbayTests/LabelTests.swift
630git commit -m "Paint label chips the colour the web paints them"
631```
632
633---
634
635### Task 3: The label management view model
636
637**Files:**
638- Create: `gitbay/Issues/LabelListViewModel.swift`
639- Test: `gitbayTests/LabelTests.swift`
640
641**Interfaces:**
642- Consumes: `IssueLabel` (Task 1), `LoadState`, `GitbayClient.readList(_:of:)`,
643 `GitbayClient.run(_:stdin:)`
644- Produces: `LabelListViewModel` with `state: LoadState<[IssueLabel]>`,
645 `actionError: String?`, `working: Bool`, `repoPath: String`,
646 `load()`, `setLabel(_ name: String, color: String?)`, `remove(_ name: String)`
647
648Follows `MilestoneListViewModel` for the load half and
649`RepoSettingsViewModel.perform(argv:)` (`:89-101`) for the write half, copied
650verbatim. It is eight lines, and the two existing copies are not this merge
651request's to refactor.
652
653- [ ] **Step 1: Write the failing tests**
654
655Append to `gitbayTests/LabelTests.swift`:
656
657```swift
658@MainActor
659struct LabelListViewModelTests {
660
661 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
662 let (client, stub) = try makeClient()
663 stub.enqueue(.init(status: 200, json: labelListJSON))
664 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
665 await model.load()
666 return (model, stub)
667 }
668
669 @Test func listsLabelsWithTheirUseCounts() async throws {
670 let (model, stub) = try await loaded()
671
672 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
673 #expect(model.state.value?.map(\.issues) == [18, 11])
674 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
675 }
676
677 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
678 let (client, stub) = try makeClient()
679 stub.enqueue(.init(status: 200, json: """
680 {"protocol_version":1,"exit_code":0}
681 """))
682 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
683 await model.load()
684
685 guard case .empty = model.state else {
686 Testing.Issue.record("expected an empty state, got \(model.state)")
687 return
688 }
689 }
690
691 @Test func settingAColourSendsTheColourFlag() async throws {
692 let (model, stub) = try await loaded()
693 stub.enqueue(.init(status: 200, json: """
694 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
695 """))
696 stub.enqueue(.init(status: 200, json: labelListJSON))
697
698 await model.setLabel("bug", color: "cf222e")
699
700 let write = try #require(stub.seen.first { $0.method == "POST" })
701 #expect(try argvOf(write)
702 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
703 }
704
705 /// A label created without a colour must not send `--color`, which
706 /// would be "clear it" rather than "leave whatever it has".
707 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
708 let (model, stub) = try await loaded()
709 stub.enqueue(.init(status: 200, json: """
710 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
711 """))
712 stub.enqueue(.init(status: 200, json: labelListJSON))
713
714 await model.setLabel("new", color: nil)
715
716 let write = try #require(stub.seen.first { $0.method == "POST" })
717 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
718 }
719
720 /// An empty string is not nil: it clears the colour.
721 @Test func clearingSendsAnEmptyColour() async throws {
722 let (model, stub) = try await loaded()
723 stub.enqueue(.init(status: 200, json: """
724 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
725 """))
726 stub.enqueue(.init(status: 200, json: labelListJSON))
727
728 await model.setLabel("bug", color: "")
729
730 let write = try #require(stub.seen.first { $0.method == "POST" })
731 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
732 }
733
734 @Test func removingSendsTheRemoveCommand() async throws {
735 let (model, stub) = try await loaded()
736 stub.enqueue(.init(status: 200, json: """
737 {"protocol_version":1,"exit_code":0}
738 """))
739 stub.enqueue(.init(status: 200, json: labelListJSON))
740
741 await model.remove("bug")
742
743 let write = try #require(stub.seen.first { $0.method == "POST" })
744 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
745 }
746
747 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
748 let (model, stub) = try await loaded()
749 stub.enqueue(.init(status: 403, json: """
750 {"protocol_version":1,"error":"write access required","exit_code":4}
751 """))
752
753 await model.remove("bug")
754
755 #expect(model.actionError?.isEmpty == false)
756 #expect(model.working == false)
757 }
758}
759```
760
761`Testing.Issue.record` is spelled with its module because this app has its own
762`Issue` model, and `@testable import gitbay` makes the app's win.
763
764- [ ] **Step 2: Run the tests to verify they fail**
765
766Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests/LabelListViewModelTests`
767
768Expected: build failure — `cannot find 'LabelListViewModel' in scope`.
769
770- [ ] **Step 3: Write the implementation**
771
772Create `gitbay/Issues/LabelListViewModel.swift`:
773
774```swift
775import Foundation
776import Observation
777
778/// `label list <repo>`, plus the two commands that change the set:
779/// `label set` creates one or recolours it, `label remove` takes it off
780/// the repository and off every issue that carried it.
781@Observable
782@MainActor
783final class LabelListViewModel {
784
785 private(set) var state: LoadState<[IssueLabel]> = .loading
786 private(set) var actionError: String?
787 private(set) var working = false
788
789 private let client: GitbayClient
790 let repoPath: String
791
792 init(client: GitbayClient, repoPath: String) {
793 self.client = client
794 self.repoPath = repoPath
795 }
796
797 func load() async {
798 state = .loading
799 do {
800 let labels = try await client.readList(
801 ["label", "list", repoPath], of: IssueLabel.self
802 )
803 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
804 } catch {
805 state = .from(error)
806 }
807 }
808
809 /// Create a label or set its colour. A nil colour omits `--color`
810 /// entirely, which the server reads as "make sure it exists" and
811 /// leaves any colour it already had; an empty string clears it.
812 func setLabel(_ name: String, color: String?) async {
813 var argv = ["label", "set", repoPath, name]
814 if let color { argv.append(contentsOf: ["--color", color]) }
815 await perform(argv)
816 }
817
818 func remove(_ name: String) async {
819 await perform(["label", "remove", repoPath, name])
820 }
821
822 private func perform(_ argv: [String]) async {
823 working = true
824 actionError = nil
825 defer { working = false }
826 do {
827 try await client.run(argv)
828 await load()
829 } catch let error as GitbayError {
830 actionError = error.userFacingMessage
831 } catch {
832 actionError = GitbayError.transport(error).userFacingMessage
833 }
834 }
835}
836```
837
838- [ ] **Step 4: Run the tests to verify they pass**
839
840Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
841
842Expected: PASS.
843
844- [ ] **Step 5: Commit**
845
846```bash
847git add gitbay/Issues/LabelListViewModel.swift gitbayTests/LabelTests.swift
848git commit -m "Label list view model: list, set colour, remove"
849```
850
851---
852
853### Task 4: The management screen and its route
854
855**Files:**
856- Create: `gitbay/Views/Issues/LabelListView.swift`
857- Modify: `gitbay/Views/Repos/RepoRoute.swift`
858- Modify: `gitbay/ContentView.swift` (the `RepoRoute` switch, beside
859 `case .milestones` at `:99`)
860- Modify: `gitbay/Views/Repos/RepoView.swift` (after the Milestones link at `:43-45`)
861
862**Interfaces:**
863- Consumes: `LabelListViewModel` (Task 3), `LabelPalette`, `LabelColors`,
864 `IssueLabel` (Task 1), `LoadStateOverlay`, `GBChip`, `GBNotice`
865- Produces: `RepoRoute.labels(repo: String)`, `LabelListView(client:repo:)`
866
867`label remove` takes the label off every issue that carried it, so removal is
868confirmed before it fires. Creating and recolouring are not.
869
870- [ ] **Step 1: Add the route case**
871
872In `gitbay/Views/Repos/RepoRoute.swift`, after `case milestones(repo: String)`:
873
874```swift
875 case labels(repo: String)
876```
877
878- [ ] **Step 2: Write the view**
879
880Create `gitbay/Views/Issues/LabelListView.swift`:
881
882```swift
883import SwiftUI
884
885/// A repository's labels: their colour, how many issues carry each, and
886/// the controls to create, recolour and remove them.
887struct LabelListView: View {
888
889 @State private var model: LabelListViewModel
890 @State private var newLabel = ""
891 @State private var pendingRemoval: IssueLabel?
892
893 init(client: GitbayClient, repo: String) {
894 _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo))
895 }
896
897 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
898
899 var body: some View {
900 List {
901 if let actionError = model.actionError {
902 Section { GBNotice(actionError) }
903 }
904
905 Section("New label") {
906 HStack {
907 TextField("Label", text: $newLabel)
908 .autocorrectionDisabled()
909 .textInputAutocapitalization(.never)
910 Button {
911 let name = newLabel.trimmingCharacters(in: .whitespaces)
912 newLabel = ""
913 Task { await model.setLabel(name, color: nil) }
914 } label: {
915 Image(systemName: "plus.circle.fill")
916 }
917 .disabled(
918 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
919 )
920 }
921 }
922
923 ForEach(model.state.value ?? []) { label in
924 LabelRow(label: label, color: colors.color(label.name), working: model.working) {
925 hex in
926 Task { await model.setLabel(label.name, color: hex) }
927 } remove: {
928 pendingRemoval = label
929 }
930 }
931 }
932 .overlay { LoadStateOverlay(state: model.state) }
933 .navigationTitle("Labels")
934 .navigationBarTitleDisplayMode(.inline)
935 .task { await model.load() }
936 .refreshable { await model.load() }
937 .confirmationDialog(
938 "Remove \(pendingRemoval?.name ?? "")?",
939 isPresented: .init(
940 get: { pendingRemoval != nil },
941 set: { if !$0 { pendingRemoval = nil } }
942 ),
943 titleVisibility: .visible
944 ) {
945 Button("Remove", role: .destructive) {
946 guard let label = pendingRemoval else { return }
947 pendingRemoval = nil
948 Task { await model.remove(label.name) }
949 }
950 Button("Cancel", role: .cancel) { pendingRemoval = nil }
951 } message: {
952 // The command takes the label off every issue, not just the set.
953 Text("This takes the label off every issue that carries it.")
954 }
955 }
956}
957
958private struct LabelRow: View {
959 let label: IssueLabel
960 let color: Color
961 let working: Bool
962 let recolour: (String) -> Void
963 let remove: () -> Void
964
965 var body: some View {
966 HStack(spacing: 8) {
967 GBChip(label.name, color)
968 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
969 .font(.gbSans(.caption))
970 .foregroundStyle(.secondary)
971 Spacer()
972 Menu {
973 ForEach(LabelPalette.palette, id: \.self) { hex in
974 Button(hex) { recolour(String(hex.dropFirst())) }
975 }
976 Button("Clear colour") { recolour("") }
977 Button("Remove", role: .destructive) { remove() }
978 } label: {
979 Image(systemName: "ellipsis.circle")
980 }
981 .disabled(working)
982 }
983 }
984}
985```
986
987- [ ] **Step 3: Wire the route**
988
989In `gitbay/ContentView.swift`, in the `RepoRoute` switch after
990`case .milestones(let repo)` at `:99-100`:
991
992```swift
993 case .labels(let repo):
994 LabelListView(client: client, repo: repo)
995```
996
997In `gitbay/Views/Repos/RepoView.swift`, after the Milestones link at `:43-45`:
998
999```swift
1000 NavigationLink(value: RepoRoute.labels(repo: path)) {
1001 Label("Labels", systemImage: "tag")
1002 }
1003```
1004
1005That `Label` is SwiftUI's, which is exactly why the model is called
1006`IssueLabel`.
1007
1008- [ ] **Step 4: Build and check the screen**
1009
1010Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
1011
1012Expected: PASS. Then launch the app against a real repository and confirm:
1013labels list with colours and counts; creating one adds it; recolouring repaints
1014the chip; removing asks first and then removes.
1015
1016- [ ] **Step 5: Commit**
1017
1018```bash
1019git add gitbay/Views/Issues/LabelListView.swift gitbay/Views/Repos/RepoRoute.swift \
1020 gitbay/ContentView.swift gitbay/Views/Repos/RepoView.swift
1021git commit -m "Label management screen"
1022```
1023
1024---
1025
1026### Task 5: Flip the parity row and open the merge request
1027
1028The wiki page is updated in the merge request that changes a row — that is what
1029the page itself requires.
1030
1031**Files:**
1032- Modify: `krz/gitbay`, `.gitbay/wiki/Parity.org`, the Issues table
1033
1034- [ ] **Step 1: Flip the row**
1035
1036In the Issues table, `labels: list, colour` becomes `yes` in the `ios` column.
1037Touch no other row — the stale-row corrections are their own merge request on
1038`krz/gitbay`, not this one.
1039
1040The wiki lives at `.gitbay/wiki/` on the default branch, so this is an edit to a
1041file in the repository — a push or `repo commit-file`, not a wiki-specific
1042command.
1043
1044- [ ] **Step 2: Run the full suite one more time**
1045
1046Run: `xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'platform=iOS Simulator,name=iPhone 17' test -only-testing:gitbayTests`
1047
1048Expected: PASS. Record the actual count; do not claim a number you have not seen.
1049
1050- [ ] **Step 3: Open the merge request**
1051
1052```bash
1053gitbay mr create --source labels-colour --target main \
1054 --title "Labels: list, colour, and a management screen" --file - <<'EOF'
1055`label list` reaches the app, so chips carry the colour the web paints
1056them: the stored `#rrggbb` when set, otherwise one picked from the same
1057eight-colour palette by an FNV-1a hash of the name, clamped to the same
1058luminance window. The issue list and issue detail both paint them.
1059
1060A Labels screen under the repository creates a label, sets its colour and
1061removes one, dispatching `label set` and `label remove`. Removal is
1062confirmed first, since it takes the label off every issue.
1063
1064Closes the `labels: list, colour` parity row.
1065EOF
1066```
1067
1068---
1069
1070## Notes for whoever executes this
1071
1072**The colour tests are the ones that matter.** Everything else here is
1073scaffolding around a screen. If the golden values in Task 1 fail, do not adjust
1074them to match the code — they were computed from the web's algorithm, and a
1075mismatch means the port is wrong, not the expectation.
1076
1077**`Label` is taken.** SwiftUI's `Label` is used 32 times in `gitbay/`. The model
1078is `IssueLabel` for that reason; do not rename it back.
1079
1080**`Issue` is ambiguous in tests.** This app has an `Issue` model and Swift
1081Testing has an `Issue` type for recording failures. Under `@testable import
1082gitbay` the app's wins, so spell Swift Testing's `Testing.Issue`.
1083
1084**`label set` without `--color` is not the same as with an empty one.** Omitted
1085means "make sure this label exists, keep any colour it has". `--color ''` clears
1086it. The view model models this as `nil` vs `""` and three tests pin the
1087difference.
1088
1089**What MR 2 consumes from this.** `IssueListViewModel.labels` is the array MR
10902's filter picker offers, and `IssueListViewModel.colors` paints the chips
1091inside it. Neither is private; do not narrow them.
docs/superpowers/specs/2026-09-06-ios-parity-design.md added +313
@@ -0,0 +1,313 @@
1# Closing the iOS parity gaps
2
3Design for clearing every outstanding `no` in the iOS column of
4`krz/gitbay`'s Parity wiki page, minus the rows that are `no` by design.
5
6Instance: gitbay.org · Forge: krz/gitbay @ 6bcd070 · Wiki: krz/gitbay/wiki/Parity
7
8## The invariant
9
10The app adds no capability the CLI does not have. Every screen below is a
11rendering of a control command that already exists in the registry. Where a
12capability is missing from the registry, the command is added on the server —
13never special-cased here. Where a command refuses something, the app surfaces
14the refusal.
15
16## Gap inventory
17
18The iOS column carries twenty-four `no` rows. Nineteen are buildable, four are
19`no` by design or should be `n/a`, and one is already done.
20
21### Already shipped — the wiki row is stale
22
23`profile about and links`. `ProfileViewModel.Profile` decodes `about`,
24`about_format` and `links`, and `ProfileView.aboutSection` renders the about
25text through `ReadmeView` under `about.org` or `about.md` while the links
26render as a list (commit `8d2421a`). The wiki's note that "the iOS `Profile`
27decoder lists its keys explicitly, so it ignores `about` and `links`" describes
28a state that no longer exists.
29
30### Not iOS work
31
32| Row | Why |
33|-----|-----|
34| API token mint | SSH-only by design, per the wiki's own rule |
35| delete, transfer | SSH-only by design; wants a typed confirmation |
36| request a login link | The app authenticates with a pasted bearer token. A mailed one-time web link has nothing to unlock in a native client |
37| account export | A JSON bundle has nowhere useful to land on a phone — the same argument the wiki already accepts for archive download |
38
39The last two are proposed as `n/a` reclassifications, not deferrals.
40
41`choose body markup` is *not* on this list. It is a genuine gap and MR 13 builds
42it. The rule the wiki states — a capability lands over SSH first, then on the
43web in the same merge request — governs SSH to web, not web to iOS, so the web
44also being `no` does not order the two surfaces. The wiki says as much: both
45`no`s are "the absence of a picker on the web form and in the iOS composer",
46which is missing UI, not a rule.
47
48### Buildable
49
50| Row | Command | MR |
51|-----|---------|----|
52| labels: list, colour | `label list`, `label set`, `label remove` | 1 |
53| issue filter by label, assignee, author, milestone | `issue list --label --assignee --author --milestone` | 2 |
54| issue search title and body | `issue list --search` | 2 |
55| MR search title and body | `mr list --search` | 2 |
56| search issues and merge requests | `search <q> [--kind repo\|issue\|mr]` | 3 |
57| MR draft, ready | `mr draft`, `mr ready` | 4 |
58| MR retarget | `mr retarget <repo> <n> <branch>` | 4 |
59| stacked merge requests | `stacked_on` / `stacked` on `mr list`, `mr show` | 4 |
60| compare two refs | `repo diff <repo> <base> <head>` | 5 |
61| notification inbox | `notifications list`, `notifications read` | 6 |
62| watch, mute | `repo watch`, `repo unwatch` | 7 |
63| build cancel | `build cancel <repo> <n>` | 8 |
64| dependency checks on/off | `repo deps enable`, `repo deps disable` | 9 |
65| dependency status | `repo deps status` | 9 |
66| release delete | `release delete <repo> <tag> --yes` | 10 |
67| org create, rename | `org create`, `org rename` | 11 |
68| profile set | `profile set` | 12 |
69| choose body markup (MRs) | `--format md\|org` on `mr create`, `mr edit`, `mr comment` | 13 |
70| choose body markup (issues) | `--format md\|org` on `issue create`, `issue edit`, `issue comment` | 13 |
71
72## What the wire already carries
73
74Three rows need no server change and no new command, only decoding. The fields
75exist and are `omitempty`, which is why they are absent from a sample response
76rather than unimplemented:
77
78- `draft` — `internal/control/mr.go:337`, on both `mr list` and `mr show`
79- `stacked_on`, `stacked` — `internal/control/mr.go:349-350`, each a
80 `{number, title}` reference
81- `color` — `internal/store/labels.go:8`, on `label list` rows
82
83The decoding tests for these must cover the absent case, not just the present
84one. A non-draft merge request decodes with the key missing entirely.
85
86## The one server gap
87
88Watch state is not readable. `repo show` emits `default_branch`, `description`,
89`mirrors`, `path`, `protected_branches`, `topics`, `visibility` and `website` —
90no watch field. `store.RepoWatchState` (`internal/store/inbox.go:114`) returns
91`watching`, `muted` or `""` but is internal to the server and reachable from no
92command.
93
94So MR 7 ships Watch and Mute as two stateless actions that report what the
95command returned, and an issue is filed against `krz/gitbay` asking for the
96state on `repo show`. When it lands, the two actions become one toggle. The
97parity row closes on the stateless version, since the capability is present.
98
99## Components
100
101### Filter value types
102
103`IssueListViewModel` and `MRListViewModel` are 43 and 44 lines. Each owns a
104`StateFilter` enum and rebuilds `PagedListModel.argv` when it changes;
105`PagedListModel.argv` is already mutable and documented as "set by the owning
106view model when a filter changes".
107
108Replace the bare enum with a struct that renders itself to flags:
109
110```swift
111struct IssueFilter: Equatable {
112 var state: State = .open
113 var search: String = ""
114 var label: String?
115 var assignee: String?
116 var author: String?
117 var milestone: String? // a title, or "none"
118
119 /// `--state`, plus one flag per set field. Empty strings are omitted
120 /// rather than sent, since `--search ""` is not the same as no search.
121 var flags: [String]
122}
123```
124
125`MRFilter` is the same shape minus `label` and `assignee`, which `mr list` does
126not accept, plus `source_gone` in its state set. Both view models keep their
127existing didSet-reload behaviour; only the type widens.
128
129This one change carries four parity rows. It is the highest value per line in
130the plan.
131
132### Label store
133
134Labels render today as `GBChip(label, .secondary)` (`IssueListView.swift:89`) —
135one flat colour for every label, in the list only. `label list` returns
136`{name, color?, issues}` per label.
137
138A repo-scoped store reads it once and feeds three consumers: chips in the issue
139list, chips in the issue detail, and the label picker in `IssueFilter`. A label
140with no stored colour derives one from its name, matching what the web does, so
141a repository that has never set a colour still reads as coloured rather than
142uniformly grey. The derivation must produce the same hue the web produces for
143the same name — the two surfaces show the same label side by side.
144
145The management screen (`RepoRoute.labels`) lists labels with their use counts
146and dispatches `label set` to create or recolour and `label remove` to delete.
147`label remove` takes the label off every issue, so it is destructive and gated.
148
149### Splitting DiffView
150
151`DiffView.init(client:repo:number:)` (`DiffView.swift:9`) loads `mr diff`,
152parses it with `UnifiedDiff.parse`, and renders it. Compare needs the same
153renderer over a different source: `repo diff` returns
154`{base, head, merge_base, patch, truncated}`, where `patch` is the same
155unified-diff text.
156
157Split it in two: a view over `[UnifiedDiff.File]` that does the rendering, and
158two loaders that produce that array — one from `mr diff`, one from `repo diff`.
159`DiffFileSection`, `HunkView` and `LineView` are already parameterised on
160`UnifiedDiff` types and do not change.
161
162This is the only existing code the plan modifies rather than extends. The
163boundary is wrong for a second caller, and duplicating a diff renderer to avoid
164touching it would be worse.
165
166`truncated` must be surfaced. A compare across a wide range returns a partial
167patch, and a renderer that silently shows less than the whole diff is a bug the
168user cannot see.
169
170### Routes
171
172New `RepoRoute` cases: `labels(repo:)`, `compare(repo:base:head:)`, and
173`notifications`. Dependency status renders inside the existing
174`RepoSettingsView` rather than taking a route, matching where the web puts it.
175
176### Write path
177
178`AccountViewModel.perform(argv:stdin:)` and
179`RepoSettingsViewModel.perform(argv:)` are already near-duplicates of each
180other, eight lines each, setting `working`, running the command, and capturing
181`actionError`. New view models copy the pattern. A third and fourth copy is
182cheaper than a shared base class for eight lines, and the existing duplication
183is not this plan's to fix.
184
185## Merge request sequence
186
187Ordering is driven by shared machinery, not by the wiki's section order.
188
189**1. Labels.** Label store, colour on every chip, management screen. First
190because MR 2's label filter picks from real labels with real colours.
191
192**2. List filters and search.** `IssueFilter` and `MRFilter`; a search field and
193filter controls on the issue and merge request lists. Closes three rows.
194
195**3. Instance-wide search.** The Explore tab searches repositories today through
196`repo search` (`RepoListViewModel.swift:63`). Widen it to `search`, which covers
197repositories plus the title and body of every issue and merge request the caller
198can read, with a kind control for `repo | issue | mr`. Results carry
199`{kind, repo, number, title, author, state, updated_at}` and route to the
200existing repo, issue and merge request destinations. What the user types is
201quoted term by term server-side, so an FTS5 operator is a word to match — the
202app passes the query through unescaped.
203
204**4. Draft, ready, retarget, stack.** Decode `draft`, `stacked_on` and
205`stacked`. A draft badge on list rows and the detail header. Draft and Ready
206actions. Retarget picks a branch from the repo's refs and states that the
207reviews go stale, because an approval was of the diff against the old branch. A
208stack section links both directions — what this is stacked on, and what is
209stacked on it. A squash or rebase merge is refused while anything is stacked on
210the merge request, so the merge sheet hides those strategies rather than letting
211the user pick one the server will reject. `mr create` also takes `--draft`, so
212the create form gains the checkbox in the same merge request. Closes three rows.
213
214**5. Compare two refs.** The `DiffView` split, then a compare screen reachable
215from the Refs screen with two ref pickers. Renders `merge_base` and handles
216`truncated`.
217
218**6. Notification inbox.** `notifications list` pages on `PagedListModel` with
219the same cursors as every other list; `--all` toggles between unread and
220everything. Items carry `{id, repo, kind, actor, summary, path, created_at,
221read_at?}`; `path` routes to the thread. `notifications read <id>...` and
222`--all` clear. The unread count sits on the Dashboard, where the web puts it in
223its rail.
224
225**7. Watch and mute.** Two actions in repo settings. Stateless, per the server
226gap above.
227
228**8. Build cancel.** An action on build detail, offered for queued and running
229builds, confirmed before firing. Cancelling withdraws a queued build or ends a
230running one within a couple of seconds; cancelling a duplicate of a commit that
231already passed puts that result back on the commit.
232
233**9. Dependency checks.** A toggle in repo settings dispatching `repo deps
234enable` and `repo deps disable`, with the status report under it: `enabled`,
235`last_check`, `last_error`, a link to the tracking issue `issue_number`, and the
236`behind` rows of `{ecosystem, name, current, latest}`. Checks are off until a
237repository's admin turns them on. Closes two rows.
238
239**10. Release delete.** A destructive action on release detail. `release delete`
240requires `--yes`, so the app confirms before sending it, and the confirmation
241says the assets go too.
242
243**11. Org create and rename.** Create from the org list. Rename in org settings,
244stating that clone URLs change. Note `org delete` is `n/a` on iOS, and stays so.
245
246**12. Profile set.** An edit form on your own profile: description, website,
247about text with a `md | org` format picker, and up to five labelled links. The
248server caps the list at five and `''` clears a field. `profile set` is not
249`SSHOnly` — nothing about a bio is a credential, and the JSON API runs it — so
250the `no` on every surface is missing UI rather than a rule.
251
252**13. Body markup picker.** `--format md|org` on `issue create`, `issue edit`,
253`issue comment`, `mr create`, `mr edit` and `mr comment`. Nothing in the compose
254path mentions a format today: `ComposeSheet` is a title-and-body form shared by
255the issue and merge request create and edit sheets, and comments compose
256separately in `IssueView` and `MRView`. So the picker lands in two places, not
257one — an optional format binding on `ComposeSheet` covers create and edit for
258both nouns, and each comment field gains its own. The stored format is
259what every surface renders from, so the picker's default matters: editing an
260existing body defaults to the format that body was stored with, read from
261`body_format` on `issue show` and `mr show`, and changing it reinterprets prose
262that already exists. New bodies default to markdown, as they do now. Diff-line
263comments have no format column and are always markdown, so `mr diff-comment` is
264untouched. Closes two rows.
265
266`release create` and `release edit` take `--format` too, but the wiki has no
267parity row for release markup, so that is left alone rather than widened
268silently.
269
270Nineteen rows, thirteen merge requests. Each flips its own parity rows in the
271same merge request, which is what the wiki page requires.
272
273## Work in krz/gitbay
274
275One merge request, landing before the iOS work:
276
277- Flip `profile about and links` to `yes` on iOS.
278- Reclassify `request a login link` and `account export` from `no` to `n/a` in
279 the iOS column, each with its reason. `API token mint` and `delete, transfer`
280 are already covered by the SSH-only section and need no row change.
281
282One issue:
283
284- `repo show` should carry watch state, so a client can render a toggle rather
285 than two buttons. `store.RepoWatchState` already computes it.
286
287## Testing
288
289`gitbayTests` is hermetic and runs offline; network calls go through a stubbed
290`URLProtocol`. Every merge request adds view-model tests there. Two kinds carry
291the weight:
292
293**Argv shape.** A filter that renders a flag `issue list` does not accept is a
294server error the app cannot catch at compile time, and the failure surfaces as
295an empty list rather than a crash. Each filter combination asserts its exact
296argv.
297
298**Absent-field decoding.** For `draft`, `stacked_on`, `stacked` and `color`, the
299test that matters is the one where the key is missing, since that is the common
300case on the wire and the case a fixture written by hand tends to omit.
301
302The live smoke suite in `gitbayUITests` stays opt-in and is not extended by this
303plan.
304
305## Stopping early
306
307The order is chosen so that stopping is cheap. Merge requests 1 through 6 clear
308the whole triage, review and respond loop — the app's stated brief — and 13
309belongs to it too, but sits last because it touches the composer every other
310merge request also touches and is cheaper once they have landed. 7 through 12
311are repository and account administration, which is the part of the surface a
312phone is least likely to be the right tool for. If the plan is cut, it is cut
313after 6, with 13 pulled forward.
gitbay/ContentView.swift +2
@@ -98,6 +98,8 @@ private struct RouteDestinations: ViewModifier {
9898 RefsView(client: client, repo: repo)
9999 case .milestones(let repo):
100100 MilestoneListView(client: client, repo: repo)
101 case .labels(let repo):
102 LabelListView(client: client, repo: repo)
101103 case .wiki(let repo):
102104 WikiView(client: client, repo: repo)
103105 case .wikiPage(let repo, let page):
gitbay/Issues/IssueDetailViewModel.swift +12
@@ -12,6 +12,10 @@ final class IssueDetailViewModel {
1212 private(set) var working = false
1313 /// Open milestones for the picker; fetched on first use.
1414 private(set) var availableMilestones: [Milestone]?
15 private(set) var colors = LabelColors()
16 /// Labels rarely change mid-session, and every write reloads via
17 /// `perform(argv:)`: fetch them once per view model, not on every reload.
18 private var labelsLoaded = false
1519
1620 private let client: GitbayClient
1721 let repoPath: String
@@ -30,7 +34,15 @@ final class IssueDetailViewModel {
3034 state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
3135 } catch {
3236 state = .from(error)
37 return
3338 }
39 guard !labelsLoaded else { return }
40 labelsLoaded = true
41 colors = LabelColors(await labelList())
42 }
43
44 private func labelList() async -> [IssueLabel] {
45 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
3446 }
3547
3648 /// Both fields are always sent: the title as given, the body over
gitbay/Issues/IssueLabel.swift added +114
@@ -0,0 +1,114 @@
1import SwiftUI
2
3/// One row of `label list`: the label, the colour it was set to, and how
4/// many issues carry it.
5///
6/// Named `IssueLabel` rather than `Label` because SwiftUI's `Label` is
7/// used throughout this module and a bare `Label` here would shadow it.
8nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable {
9 let name: String
10 /// `#rrggbb` when set, absent otherwise — `label set` normalises to
11 /// lowercase with the hash, so any other shape never reached the store.
12 let color: String?
13 let issues: Int
14
15 var id: String { name }
16}
17
18/// Chip colours for labels, matching what the web paints
19/// (`internal/httpd/web.go`, `labelColors` and `clampChip`) so the same
20/// label reads the same on both surfaces.
21nonisolated enum LabelPalette {
22
23 /// Mid-tone hues that stay legible as text on both grounds. Every
24 /// entry already sits inside the window `clamped` holds, so a derived
25 /// colour is a plain lookup and only a stored one is ever adjusted.
26 static let palette = [
27 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
28 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
29 ]
30
31 /// The colour the web would paint for this label: the stored one when
32 /// it is a hex colour, otherwise one picked from the palette by name.
33 static func hex(name: String, stored: String?) -> String {
34 guard let stored, let parsed = parse(stored) else {
35 return palette[Int(fnv1a(name) % UInt32(palette.count))]
36 }
37 return clamped(parsed)
38 }
39
40 /// `#rrggbb` to its three channels, or nil for anything else.
41 private static func parse(_ hex: String) -> (Double, Double, Double)? {
42 guard hex.count == 7, hex.hasPrefix("#") else { return nil }
43 let digits = hex.dropFirst()
44 guard digits.allSatisfy(\.isHexDigit),
45 let value = UInt32(digits, radix: 16) else { return nil }
46 return (
47 Double((value >> 16) & 0xFF) / 255,
48 Double((value >> 8) & 0xFF) / 255,
49 Double(value & 0xFF) / 255
50 )
51 }
52
53 /// Hold relative luminance between 0.12 and 0.28, where a chip clears
54 /// 3:1 against white and against the dark ground alike. The hue is
55 /// kept; the channels are scaled in linear light.
56 private static func clamped(_ rgb: (Double, Double, Double)) -> String {
57 let (r, g, b) = (linear(rgb.0), linear(rgb.1), linear(rgb.2))
58 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b
59 let lo = 0.12, hi = 0.28
60 if y >= lo, y <= hi { return format(r, g, b) }
61 let target = y < lo ? lo : hi
62 guard y != 0 else { return format(target, target, target) }
63 let k = target / y
64 return format(min(1, r * k), min(1, g * k), min(1, b * k))
65 }
66
67 private static func linear(_ c: Double) -> Double {
68 c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
69 }
70
71 private static func format(_ r: Double, _ g: Double, _ b: Double) -> String {
72 func encode(_ v: Double) -> Int {
73 let s = v <= 0.0031308 ? v * 12.92 : 1.055 * pow(v, 1 / 2.4) - 0.055
74 return Int((s * 255).rounded())
75 }
76 return String(format: "#%02x%02x%02x", encode(r), encode(g), encode(b))
77 }
78
79 /// FNV-1a, 32-bit. The web hashes the name with it to index the
80 /// palette, so this has to agree bit for bit.
81 private static func fnv1a(_ text: String) -> UInt32 {
82 var hash: UInt32 = 2_166_136_261
83 for byte in text.utf8 {
84 hash ^= UInt32(byte)
85 hash &*= 16_777_619
86 }
87 return hash
88 }
89}
90
91/// The chip colour for every label in one repository. Built from one
92/// `label list` read; a label it has never heard of still derives a
93/// colour from its name rather than falling back to grey.
94nonisolated struct LabelColors: Sendable, Hashable {
95
96 private let stored: [String: String]
97
98 init(_ labels: [IssueLabel] = []) {
99 stored = labels.reduce(into: [:]) { map, label in
100 map[label.name] = label.color ?? ""
101 }
102 }
103
104 func hex(_ name: String) -> String {
105 LabelPalette.hex(name: name, stored: stored[name])
106 }
107
108 func color(_ name: String) -> Color {
109 guard let value = UInt32(hex(name).dropFirst(), radix: 16) else {
110 return .secondary
111 }
112 return Color(UIColor(hex: value))
113 }
114}
gitbay/Issues/IssueListViewModel.swift +20
@@ -13,6 +13,7 @@ final class IssueListViewModel {
1313
1414 let list: PagedListModel<Issue>
1515 let repoPath: String
16 private let client: GitbayClient
1617 var filter: StateFilter = .open {
1718 didSet {
1819 guard filter != oldValue else { return }
@@ -21,7 +22,17 @@ final class IssueListViewModel {
2122 }
2223 }
2324
25 /// This repository's labels, loaded beside the issues: the names for
26 /// a picker, the colours for the chips. A failure here is not fatal —
27 /// every chip then derives its colour from its name.
28 private(set) var labels: [IssueLabel] = []
29 private(set) var colors = LabelColors()
30 /// Labels rarely change mid-session: fetch them once per view model,
31 /// not on every reload.
32 private var labelsLoaded = false
33
2434 init(client: GitbayClient, repoPath: String) {
35 self.client = client
2536 self.repoPath = repoPath
2637 list = PagedListModel(
2738 client: client,
@@ -34,6 +45,15 @@ final class IssueListViewModel {
3445
3546 func load() async {
3647 await list.reload()
48 if case .failed = list.state { return }
49 guard !labelsLoaded else { return }
50 labelsLoaded = true
51 labels = await labelList()
52 colors = LabelColors(labels)
53 }
54
55 private func labelList() async -> [IssueLabel] {
56 (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
3757 }
3858
3959 private func configureList() {
gitbay/Issues/LabelListViewModel.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2import Observation
3
4/// `label list <repo>`, plus the two commands that change the set:
5/// `label set` creates one or recolours it, `label remove` takes it off
6/// the repository and off every issue that carried it.
7@Observable
8@MainActor
9final class LabelListViewModel {
10
11 private(set) var state: LoadState<[IssueLabel]> = .loading
12 private(set) var actionError: String?
13 private(set) var working = false
14
15 private let client: GitbayClient
16 let repoPath: String
17
18 init(client: GitbayClient, repoPath: String) {
19 self.client = client
20 self.repoPath = repoPath
21 }
22
23 func load() async {
24 state = .loading
25 do {
26 let labels = try await client.readList(
27 ["label", "list", repoPath], of: IssueLabel.self
28 )
29 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
30 } catch {
31 state = .from(error)
32 }
33 }
34
35 /// Create a label or set its colour. A nil colour omits `--color`
36 /// entirely, which the server reads as "make sure it exists" and
37 /// leaves any colour it already had; an empty string clears it.
38 func setLabel(_ name: String, color: String?) async {
39 var argv = ["label", "set", repoPath, name]
40 if let color { argv.append(contentsOf: ["--color", color]) }
41 await perform(argv)
42 }
43
44 func remove(_ name: String) async {
45 await perform(["label", "remove", repoPath, name])
46 }
47
48 private func perform(_ argv: [String]) async {
49 working = true
50 actionError = nil
51 defer { working = false }
52 do {
53 try await client.run(argv)
54 await load()
55 } catch let error as GitbayError {
56 actionError = error.userFacingMessage
57 } catch {
58 actionError = GitbayError.transport(error).userFacingMessage
59 }
60 }
61}
gitbay/Views/Issues/IssueListView.swift +3 −2
@@ -26,7 +26,7 @@ struct IssueListView: View {
2626
2727 ForEach(model.state.value ?? []) { issue in
2828 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) {
29 IssueRow(issue: issue)
29 IssueRow(issue: issue, colors: model.colors)
3030 }
3131 }
3232 PageFooter(list: model.list)
@@ -70,6 +70,7 @@ struct IssueListView: View {
7070
7171private struct IssueRow: View {
7272 let issue: Issue
73 let colors: LabelColors
7374
7475 var body: some View {
7576 VStack(alignment: .leading, spacing: 4) {
@@ -86,7 +87,7 @@ private struct IssueRow: View {
8687 .font(.gbSans(.caption2))
8788 .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad)
8889 ForEach(issue.labels ?? [], id: \.self) { label in
89 GBChip(label, .secondary)
90 GBChip(label, colors.color(label))
9091 }
9192 Spacer()
9293 if let assignees = issue.assignees, !assignees.isEmpty {
gitbay/Views/Issues/IssueView.swift +8 −4
@@ -88,7 +88,7 @@ struct IssueView: View {
8888 @ViewBuilder
8989 private func triageSection(_ issue: IssueDetail) -> some View {
9090 Section("Labels") {
91 labelFlow(issue.labels ?? [], remove: { label in
91 labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
9292 Task { await model.removeLabel(label) }
9393 })
9494 HStack {
@@ -150,7 +150,11 @@ struct IssueView: View {
150150 }
151151
152152 @ViewBuilder
153 private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View {
153 private func labelFlow(
154 _ items: [String],
155 color: @escaping (String) -> Color = { _ in .secondary },
156 remove: @escaping (String) -> Void
157 ) -> some View {
154158 if !items.isEmpty {
155159 ScrollView(.horizontal, showsIndicators: false) {
156160 HStack(spacing: 6) {
@@ -168,8 +172,8 @@ struct IssueView: View {
168172 .font(.gbSans(.caption))
169173 .padding(.horizontal, 8)
170174 .padding(.vertical, 3)
171 .background(Color.secondary.opacity(0.07), in: gbChipShape)
172 .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1))
175 .background(color(item).opacity(0.07), in: gbChipShape)
176 .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1))
173177 }
174178 }
175179 }
gitbay/Views/Issues/LabelListView.swift added +102
@@ -0,0 +1,102 @@
1import SwiftUI
2
3/// A repository's labels: their colour, how many issues carry each, and
4/// the controls to create, recolour and remove them.
5struct LabelListView: View {
6
7 @State private var model: LabelListViewModel
8 @State private var newLabel = ""
9 @State private var pendingRemoval: IssueLabel?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo))
13 }
14
15 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
16
17 var body: some View {
18 List {
19 if let actionError = model.actionError {
20 Section { GBNotice(actionError) }
21 }
22
23 Section("New label") {
24 HStack {
25 TextField("Label", text: $newLabel)
26 .autocorrectionDisabled()
27 .textInputAutocapitalization(.never)
28 Button {
29 let name = newLabel.trimmingCharacters(in: .whitespaces)
30 newLabel = ""
31 Task { await model.setLabel(name, color: nil) }
32 } label: {
33 Image(systemName: "plus.circle.fill")
34 }
35 .disabled(
36 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
37 )
38 }
39 }
40
41 ForEach(model.state.value ?? []) { label in
42 LabelRow(label: label, color: colors.color(label.name), working: model.working) {
43 hex in
44 Task { await model.setLabel(label.name, color: hex) }
45 } remove: {
46 pendingRemoval = label
47 }
48 }
49 }
50 .overlay { LoadStateOverlay(state: model.state) }
51 .navigationTitle("Labels")
52 .navigationBarTitleDisplayMode(.inline)
53 .task { await model.load() }
54 .refreshable { await model.load() }
55 .confirmationDialog(
56 "Remove \(pendingRemoval?.name ?? "")?",
57 isPresented: .init(
58 get: { pendingRemoval != nil },
59 set: { if !$0 { pendingRemoval = nil } }
60 ),
61 titleVisibility: .visible
62 ) {
63 Button("Remove", role: .destructive) {
64 guard let label = pendingRemoval else { return }
65 pendingRemoval = nil
66 Task { await model.remove(label.name) }
67 }
68 Button("Cancel", role: .cancel) { pendingRemoval = nil }
69 } message: {
70 // The command takes the label off every issue, not just the set.
71 Text("This takes the label off every issue that carries it.")
72 }
73 }
74}
75
76private struct LabelRow: View {
77 let label: IssueLabel
78 let color: Color
79 let working: Bool
80 let recolour: (String) -> Void
81 let remove: () -> Void
82
83 var body: some View {
84 HStack(spacing: 8) {
85 GBChip(label.name, color)
86 Text(label.issues == 1 ? "1 issue" : "\(label.issues) issues")
87 .font(.gbSans(.caption))
88 .foregroundStyle(.secondary)
89 Spacer()
90 Menu {
91 ForEach(LabelPalette.palette, id: \.self) { hex in
92 Button(hex) { recolour(String(hex.dropFirst())) }
93 }
94 Button("Clear colour") { recolour("") }
95 Button("Remove", role: .destructive) { remove() }
96 } label: {
97 Image(systemName: "ellipsis.circle")
98 }
99 .disabled(working)
100 }
101 }
102}
gitbay/Views/Repos/RepoRoute.swift +1
@@ -12,6 +12,7 @@ nonisolated enum RepoRoute: Hashable {
1212 case blame(repo: String, path: String, ref: String?)
1313 case refs(repo: String)
1414 case milestones(repo: String)
15 case labels(repo: String)
1516 case wiki(repo: String)
1617 case wikiPage(repo: String, page: String)
1718 case commit(repo: String, sha: String)
gitbay/Views/Repos/RepoView.swift +3
@@ -43,6 +43,9 @@ struct RepoView: View {
4343 NavigationLink(value: RepoRoute.milestones(repo: path)) {
4444 Label("Milestones", systemImage: "flag")
4545 }
46 NavigationLink(value: RepoRoute.labels(repo: path)) {
47 Label("Labels", systemImage: "tag")
48 }
4649 NavigationLink(value: BuildRoute.list(repo: path)) {
4750 Label("Builds", systemImage: "hammer")
4851 }
gitbayTests/IssueBuildViewModelTests.swift +1 −1
@@ -84,7 +84,7 @@ struct IssueDetailViewModelTests {
8484
8585 await model.comment("triaged from the phone")
8686
87 let write = stub.seen[1]
87 let write = try #require(stub.seen.first { $0.method == "POST" })
8888 #expect(write.method == "POST")
8989 let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
9090 #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"])
gitbayTests/LabelTests.swift added +352
@@ -0,0 +1,352 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5struct LabelPaletteTests {
6
7 // Golden values computed from the web's own algorithm: FNV-1a 32 of
8 // the name, modulo the eight-colour palette. If these drift, the two
9 // surfaces paint the same label differently.
10 @Test func derivesAStableColourFromTheName() {
11 #expect(LabelPalette.hex(name: "bug", stored: nil) == "#b93a86")
12 #expect(LabelPalette.hex(name: "ci", stored: nil) == "#1a7f37")
13 #expect(LabelPalette.hex(name: "docs", stored: nil) == "#9a6700")
14 #expect(LabelPalette.hex(name: "ops", stored: nil) == "#cf222e")
15 #expect(LabelPalette.hex(name: "mobile", stored: nil) == "#bf5b16")
16 }
17
18 @Test func theSameNameAlwaysDerivesTheSameColour() {
19 #expect(LabelPalette.hex(name: "bug", stored: nil)
20 == LabelPalette.hex(name: "bug", stored: nil))
21 }
22
23 /// A stored colour already inside the luminance window is used as-is.
24 @Test func keepsAStoredColourThatIsAlreadyLegible() {
25 #expect(LabelPalette.hex(name: "bug", stored: "#ff0000") == "#ff0000")
26 #expect(LabelPalette.hex(name: "bug", stored: "#0969da") == "#0969da")
27 }
28
29 /// Too dark or too bright is scaled back into the window, so a chip
30 /// stays readable as text on both grounds.
31 @Test func clampsAStoredColourOutsideTheWindow() {
32 #expect(LabelPalette.hex(name: "bug", stored: "#000000") == "#616161")
33 #expect(LabelPalette.hex(name: "bug", stored: "#ffffff") == "#909090")
34 }
35
36 @Test func upperCaseStoredColoursNormaliseDown() {
37 #expect(LabelPalette.hex(name: "bug", stored: "#FF0000") == "#ff0000")
38 }
39
40 /// `label set` stores `#rrggbb`. Anything else — an empty string, a
41 /// bare six digits, a name — never reached the store as a colour, so
42 /// it is treated as unset and the name derives one.
43 @Test func anythingThatIsNotAHexColourFallsBackToTheName() {
44 let derived = LabelPalette.hex(name: "bug", stored: nil)
45 #expect(LabelPalette.hex(name: "bug", stored: "") == derived)
46 #expect(LabelPalette.hex(name: "bug", stored: "ff0000") == derived)
47 #expect(LabelPalette.hex(name: "bug", stored: "red") == derived)
48 #expect(LabelPalette.hex(name: "bug", stored: "#fff") == derived)
49 }
50
51 @Test func everyPaletteEntryIsAlreadyLegible() {
52 // No palette colour is ever clamped, so a derived colour is a
53 // plain lookup. If this fails the palette drifted from the web's.
54 for hex in LabelPalette.palette {
55 #expect(LabelPalette.hex(name: "x", stored: hex) == hex)
56 }
57 }
58}
59
60struct IssueLabelDecodingTests {
61
62 @Test func decodesAListRow() throws {
63 let json = Data("""
64 [{"name":"bug","color":"#cf222e","issues":18},{"name":"ci","issues":11}]
65 """.utf8)
66 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
67
68 #expect(labels.count == 2)
69 #expect(labels[0].name == "bug")
70 #expect(labels[0].color == "#cf222e")
71 #expect(labels[0].issues == 18)
72 // `color` is omitempty on the wire — absent, not null, when unset.
73 #expect(labels[1].color == nil)
74 #expect(labels[1].issues == 11)
75 }
76
77 @Test func theIdIsTheName() throws {
78 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
79 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
80 #expect(labels[0].id == "bug")
81 }
82}
83
84struct LabelColorsTests {
85
86 @Test func looksUpAStoredColour() {
87 let colors = LabelColors([
88 IssueLabel(name: "bug", color: "#cf222e", issues: 1),
89 IssueLabel(name: "ci", color: nil, issues: 2),
90 ])
91 #expect(colors.hex("bug") == "#cf222e")
92 #expect(colors.hex("ci") == "#1a7f37")
93 }
94
95 /// An issue can carry a label the map has not loaded — a stale list, or
96 /// a label added since. It still derives a colour rather than going grey.
97 @Test func anUnknownLabelStillDerivesItsColour() {
98 #expect(LabelColors().hex("docs") == "#9a6700")
99 }
100}
101
102private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
103 let box = StubProtocol.box()
104 let client = GitbayClient(
105 instance: try GitbayInstance(url: "https://gitbay.org"),
106 token: "test-token",
107 session: box.session()
108 )
109 return (client, box)
110}
111
112private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
113 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
114 return try #require(body["argv"] as? [String])
115}
116
117private let issuePageJSON = """
118 {"protocol_version":1,"data":{"items":[\
119 {"number":7,"title":"a bug","state":"open","author":"cmc",\
120 "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0}
121 """
122private let labelListJSON = """
123 {"protocol_version":1,"data":[\
124 {"name":"bug","color":"#cf222e","issues":18},\
125 {"name":"ci","issues":11}],"exit_code":0}
126 """
127
128@MainActor
129struct IssueListColorsTests {
130
131 @Test func loadsLabelsAlongsideTheIssues() async throws {
132 let (client, stub) = try makeClient()
133 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
134 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
135
136 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
137 await model.load()
138
139 #expect(model.state.value?.count == 1)
140 // The names are what MR 2's filter picker will offer.
141 #expect(model.labels.map(\.name) == ["bug", "ci"])
142 #expect(model.colors.hex("bug") == "#cf222e")
143 #expect(model.colors.hex("ci") == "#1a7f37")
144 #expect(stub.seen.contains {
145 $0.url.query() == "argv=label&argv=list&argv=krz/gitbay"
146 })
147 }
148
149 /// Decoration must not take the screen down with it.
150 @Test func issuesStillLoadWhenTheLabelReadFails() async throws {
151 let (client, stub) = try makeClient()
152 stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue"))
153 stub.enqueue(.init(status: 403, json: """
154 {"protocol_version":1,"error":"denied","exit_code":4}
155 """, match: "argv=label"))
156
157 let model = IssueListViewModel(client: client, repoPath: "krz/gitbay")
158 await model.load()
159
160 #expect(model.state.value?.count == 1)
161 #expect(model.labels.isEmpty)
162 // Derived from the name, since nothing was stored.
163 #expect(model.colors.hex("bug") == "#b93a86")
164 }
165}
166
167@MainActor
168struct IssueDetailColorsTests {
169
170 @Test func loadsLabelsAlongsideTheIssue() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: """
173 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
174 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
175 "exit_code":0}
176 """, match: "argv=show"))
177 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
178
179 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
180 await model.load()
181
182 #expect(model.state.value?.number == 7)
183 #expect(model.colors.hex("bug") == "#cf222e")
184 }
185
186 /// Decoration must not take the screen down with it.
187 @Test func issueStillLoadsWhenTheLabelReadFails() async throws {
188 let (client, stub) = try makeClient()
189 stub.enqueue(.init(status: 200, json: """
190 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
191 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
192 "exit_code":0}
193 """, match: "argv=show"))
194 stub.enqueue(.init(status: 403, json: """
195 {"protocol_version":1,"error":"denied","exit_code":4}
196 """, match: "argv=label"))
197
198 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
199 await model.load()
200
201 #expect(model.state.value?.number == 7)
202 // Derived from the name, since nothing was stored.
203 #expect(model.colors.hex("bug") == "#b93a86")
204 }
205
206 /// Regression test: `perform(argv:)` used to reload labels on every
207 /// write. Labels rarely change mid-session, so the read must happen
208 /// at most once per view model instance.
209 @Test func aWriteDoesNotReissueLabelList() async throws {
210 let (client, stub) = try makeClient()
211 stub.enqueue(.init(status: 200, json: """
212 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\
213 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
214 "exit_code":0}
215 """, match: "argv=show"))
216 stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label"))
217
218 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7)
219 await model.load()
220
221 stub.enqueue(.init(status: 200, json: """
222 {"protocol_version":1,"exit_code":0}
223 """, match: "argv=close"))
224 stub.enqueue(.init(status: 200, json: """
225 {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"closed",\
226 "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\
227 "exit_code":0}
228 """, match: "argv=show"))
229
230 await model.close()
231
232 let labelReads = stub.seen.filter { ($0.url.query() ?? "").contains("argv=label") }
233 #expect(labelReads.count == 1)
234 }
235}
236
237@MainActor
238struct LabelListViewModelTests {
239
240 private func loaded() async throws -> (LabelListViewModel, StubProtocol.Box) {
241 let (client, stub) = try makeClient()
242 stub.enqueue(.init(status: 200, json: labelListJSON))
243 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
244 await model.load()
245 return (model, stub)
246 }
247
248 @Test func listsLabelsWithTheirUseCounts() async throws {
249 let (model, stub) = try await loaded()
250
251 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
252 #expect(model.state.value?.map(\.issues) == [18, 11])
253 #expect(stub.seen.first?.url.query() == "argv=label&argv=list&argv=krz/gitbay")
254 }
255
256 @Test func aRepoWithNoLabelsIsAnEmptyState() async throws {
257 let (client, stub) = try makeClient()
258 stub.enqueue(.init(status: 200, json: """
259 {"protocol_version":1,"exit_code":0}
260 """))
261 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
262 await model.load()
263
264 guard case .empty = model.state else {
265 Testing.Issue.record("expected an empty state, got \(model.state)")
266 return
267 }
268 }
269
270 @Test func settingAColourSendsTheColourFlag() async throws {
271 let (model, stub) = try await loaded()
272 stub.enqueue(.init(status: 200, json: """
273 {"protocol_version":1,"data":{"name":"bug","color":"#cf222e"},"exit_code":0}
274 """))
275 stub.enqueue(.init(status: 200, json: labelListJSON))
276
277 await model.setLabel("bug", color: "cf222e")
278
279 let write = try #require(stub.seen.first { $0.method == "POST" })
280 #expect(try argvOf(write)
281 == ["label", "set", "krz/gitbay", "bug", "--color", "cf222e"])
282 }
283
284 /// A label created without a colour must not send `--color`, which
285 /// would be "clear it" rather than "leave whatever it has".
286 @Test func creatingWithoutAColourOmitsTheFlag() async throws {
287 let (model, stub) = try await loaded()
288 stub.enqueue(.init(status: 200, json: """
289 {"protocol_version":1,"data":{"name":"new"},"exit_code":0}
290 """))
291 stub.enqueue(.init(status: 200, json: labelListJSON))
292
293 await model.setLabel("new", color: nil)
294
295 let write = try #require(stub.seen.first { $0.method == "POST" })
296 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "new"])
297 }
298
299 /// An empty string is not nil: it clears the colour.
300 @Test func clearingSendsAnEmptyColour() async throws {
301 let (model, stub) = try await loaded()
302 stub.enqueue(.init(status: 200, json: """
303 {"protocol_version":1,"data":{"name":"bug"},"exit_code":0}
304 """))
305 stub.enqueue(.init(status: 200, json: labelListJSON))
306
307 await model.setLabel("bug", color: "")
308
309 let write = try #require(stub.seen.first { $0.method == "POST" })
310 #expect(try argvOf(write) == ["label", "set", "krz/gitbay", "bug", "--color", ""])
311 }
312
313 @Test func removingSendsTheRemoveCommand() async throws {
314 let (model, stub) = try await loaded()
315 stub.enqueue(.init(status: 200, json: """
316 {"protocol_version":1,"exit_code":0}
317 """))
318 stub.enqueue(.init(status: 200, json: labelListJSON))
319
320 await model.remove("bug")
321
322 let write = try #require(stub.seen.first { $0.method == "POST" })
323 #expect(try argvOf(write) == ["label", "remove", "krz/gitbay", "bug"])
324 }
325
326 @Test func aRefusalSurfacesRatherThanBeingSwallowed() async throws {
327 let (model, stub) = try await loaded()
328 stub.enqueue(.init(status: 403, json: """
329 {"protocol_version":1,"error":"write access required","exit_code":4}
330 """))
331
332 await model.remove("bug")
333
334 #expect(model.actionError?.isEmpty == false)
335 #expect(model.working == false)
336 }
337
338 @Test func aServerFailureSurfacesAsFailed() async throws {
339 let (client, stub) = try makeClient()
340 stub.enqueue(.init(status: 200, json: """
341 {"protocol_version":1,"error":"boom","exit_code":1}
342 """))
343 let model = LabelListViewModel(client: client, repoPath: "krz/gitbay")
344
345 await model.load()
346
347 guard case .failed = model.state else {
348 Testing.Issue.record("expected a failed state, got \(model.state)")
349 return
350 }
351 }
352}