Commit 62763741b4
Unsigned
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. | |
docs/superpowers/specs/2026-09-06-ios-parity-design.md added +313
| @@ -0,0 +1,313 @@ | ||
| 1 | # Closing the iOS parity gaps | |
| 2 | ||
| 3 | Design 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 | ||
| 6 | Instance: gitbay.org · Forge: krz/gitbay @ 6bcd070 · Wiki: krz/gitbay/wiki/Parity | |
| 7 | ||
| 8 | ## The invariant | |
| 9 | ||
| 10 | The app adds no capability the CLI does not have. Every screen below is a | |
| 11 | rendering of a control command that already exists in the registry. Where a | |
| 12 | capability is missing from the registry, the command is added on the server — | |
| 13 | never special-cased here. Where a command refuses something, the app surfaces | |
| 14 | the refusal. | |
| 15 | ||
| 16 | ## Gap inventory | |
| 17 | ||
| 18 | The 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 | |
| 25 | text through `ReadmeView` under `about.org` or `about.md` while the links | |
| 26 | render as a list (commit `8d2421a`). The wiki's note that "the iOS `Profile` | |
| 27 | decoder lists its keys explicitly, so it ignores `about` and `links`" describes | |
| 28 | a 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 | ||
| 39 | The 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 | |
| 42 | it. The rule the wiki states — a capability lands over SSH first, then on the | |
| 43 | web in the same merge request — governs SSH to web, not web to iOS, so the web | |
| 44 | also 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", | |
| 46 | which 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 | ||
| 74 | Three rows need no server change and no new command, only decoding. The fields | |
| 75 | exist and are `omitempty`, which is why they are absent from a sample response | |
| 76 | rather 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 | ||
| 83 | The decoding tests for these must cover the absent case, not just the present | |
| 84 | one. A non-draft merge request decodes with the key missing entirely. | |
| 85 | ||
| 86 | ## The one server gap | |
| 87 | ||
| 88 | Watch state is not readable. `repo show` emits `default_branch`, `description`, | |
| 89 | `mirrors`, `path`, `protected_branches`, `topics`, `visibility` and `website` — | |
| 90 | no watch field. `store.RepoWatchState` (`internal/store/inbox.go:114`) returns | |
| 91 | `watching`, `muted` or `""` but is internal to the server and reachable from no | |
| 92 | command. | |
| 93 | ||
| 94 | So MR 7 ships Watch and Mute as two stateless actions that report what the | |
| 95 | command returned, and an issue is filed against `krz/gitbay` asking for the | |
| 96 | state on `repo show`. When it lands, the two actions become one toggle. The | |
| 97 | parity 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 | |
| 106 | view model when a filter changes". | |
| 107 | ||
| 108 | Replace the bare enum with a struct that renders itself to flags: | |
| 109 | ||
| 110 | ```swift | |
| 111 | struct 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 | |
| 126 | not accept, plus `source_gone` in its state set. Both view models keep their | |
| 127 | existing didSet-reload behaviour; only the type widens. | |
| 128 | ||
| 129 | This one change carries four parity rows. It is the highest value per line in | |
| 130 | the plan. | |
| 131 | ||
| 132 | ### Label store | |
| 133 | ||
| 134 | Labels render today as `GBChip(label, .secondary)` (`IssueListView.swift:89`) — | |
| 135 | one flat colour for every label, in the list only. `label list` returns | |
| 136 | `{name, color?, issues}` per label. | |
| 137 | ||
| 138 | A repo-scoped store reads it once and feeds three consumers: chips in the issue | |
| 139 | list, chips in the issue detail, and the label picker in `IssueFilter`. A label | |
| 140 | with no stored colour derives one from its name, matching what the web does, so | |
| 141 | a repository that has never set a colour still reads as coloured rather than | |
| 142 | uniformly grey. The derivation must produce the same hue the web produces for | |
| 143 | the same name — the two surfaces show the same label side by side. | |
| 144 | ||
| 145 | The management screen (`RepoRoute.labels`) lists labels with their use counts | |
| 146 | and 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`, | |
| 152 | parses it with `UnifiedDiff.parse`, and renders it. Compare needs the same | |
| 153 | renderer over a different source: `repo diff` returns | |
| 154 | `{base, head, merge_base, patch, truncated}`, where `patch` is the same | |
| 155 | unified-diff text. | |
| 156 | ||
| 157 | Split it in two: a view over `[UnifiedDiff.File]` that does the rendering, and | |
| 158 | two 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 | ||
| 162 | This is the only existing code the plan modifies rather than extends. The | |
| 163 | boundary is wrong for a second caller, and duplicating a diff renderer to avoid | |
| 164 | touching it would be worse. | |
| 165 | ||
| 166 | `truncated` must be surfaced. A compare across a wide range returns a partial | |
| 167 | patch, and a renderer that silently shows less than the whole diff is a bug the | |
| 168 | user cannot see. | |
| 169 | ||
| 170 | ### Routes | |
| 171 | ||
| 172 | New `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 | |
| 180 | other, eight lines each, setting `working`, running the command, and capturing | |
| 181 | `actionError`. New view models copy the pattern. A third and fourth copy is | |
| 182 | cheaper than a shared base class for eight lines, and the existing duplication | |
| 183 | is not this plan's to fix. | |
| 184 | ||
| 185 | ## Merge request sequence | |
| 186 | ||
| 187 | Ordering 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 | |
| 190 | because 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 | |
| 193 | filter 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 | |
| 197 | repositories plus the title and body of every issue and merge request the caller | |
| 198 | can read, with a kind control for `repo | issue | mr`. Results carry | |
| 199 | `{kind, repo, number, title, author, state, updated_at}` and route to the | |
| 200 | existing repo, issue and merge request destinations. What the user types is | |
| 201 | quoted term by term server-side, so an FTS5 operator is a word to match — the | |
| 202 | app 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 | |
| 206 | actions. Retarget picks a branch from the repo's refs and states that the | |
| 207 | reviews go stale, because an approval was of the diff against the old branch. A | |
| 208 | stack section links both directions — what this is stacked on, and what is | |
| 209 | stacked on it. A squash or rebase merge is refused while anything is stacked on | |
| 210 | the merge request, so the merge sheet hides those strategies rather than letting | |
| 211 | the user pick one the server will reject. `mr create` also takes `--draft`, so | |
| 212 | the 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 | |
| 215 | from 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 | |
| 219 | the same cursors as every other list; `--all` toggles between unread and | |
| 220 | everything. Items carry `{id, repo, kind, actor, summary, path, created_at, | |
| 221 | read_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 | |
| 223 | its rail. | |
| 224 | ||
| 225 | **7. Watch and mute.** Two actions in repo settings. Stateless, per the server | |
| 226 | gap above. | |
| 227 | ||
| 228 | **8. Build cancel.** An action on build detail, offered for queued and running | |
| 229 | builds, confirmed before firing. Cancelling withdraws a queued build or ends a | |
| 230 | running one within a couple of seconds; cancelling a duplicate of a commit that | |
| 231 | already passed puts that result back on the commit. | |
| 232 | ||
| 233 | **9. Dependency checks.** A toggle in repo settings dispatching `repo deps | |
| 234 | enable` 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 | |
| 237 | repository's admin turns them on. Closes two rows. | |
| 238 | ||
| 239 | **10. Release delete.** A destructive action on release detail. `release delete` | |
| 240 | requires `--yes`, so the app confirms before sending it, and the confirmation | |
| 241 | says the assets go too. | |
| 242 | ||
| 243 | **11. Org create and rename.** Create from the org list. Rename in org settings, | |
| 244 | stating 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, | |
| 247 | about text with a `md | org` format picker, and up to five labelled links. The | |
| 248 | server 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 | |
| 250 | the `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 | |
| 254 | path mentions a format today: `ComposeSheet` is a title-and-body form shared by | |
| 255 | the issue and merge request create and edit sheets, and comments compose | |
| 256 | separately in `IssueView` and `MRView`. So the picker lands in two places, not | |
| 257 | one — an optional format binding on `ComposeSheet` covers create and edit for | |
| 258 | both nouns, and each comment field gains its own. The stored format is | |
| 259 | what every surface renders from, so the picker's default matters: editing an | |
| 260 | existing 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 | |
| 262 | that already exists. New bodies default to markdown, as they do now. Diff-line | |
| 263 | comments have no format column and are always markdown, so `mr diff-comment` is | |
| 264 | untouched. Closes two rows. | |
| 265 | ||
| 266 | `release create` and `release edit` take `--format` too, but the wiki has no | |
| 267 | parity row for release markup, so that is left alone rather than widened | |
| 268 | silently. | |
| 269 | ||
| 270 | Nineteen rows, thirteen merge requests. Each flips its own parity rows in the | |
| 271 | same merge request, which is what the wiki page requires. | |
| 272 | ||
| 273 | ## Work in krz/gitbay | |
| 274 | ||
| 275 | One 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 | ||
| 282 | One 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 | |
| 291 | the weight: | |
| 292 | ||
| 293 | **Argv shape.** A filter that renders a flag `issue list` does not accept is a | |
| 294 | server error the app cannot catch at compile time, and the failure surfaces as | |
| 295 | an empty list rather than a crash. Each filter combination asserts its exact | |
| 296 | argv. | |
| 297 | ||
| 298 | **Absent-field decoding.** For `draft`, `stacked_on`, `stacked` and `color`, the | |
| 299 | test that matters is the one where the key is missing, since that is the common | |
| 300 | case on the wire and the case a fixture written by hand tends to omit. | |
| 301 | ||
| 302 | The live smoke suite in `gitbayUITests` stays opt-in and is not extended by this | |
| 303 | plan. | |
| 304 | ||
| 305 | ## Stopping early | |
| 306 | ||
| 307 | The order is chosen so that stopping is cheap. Merge requests 1 through 6 clear | |
| 308 | the whole triage, review and respond loop — the app's stated brief — and 13 | |
| 309 | belongs to it too, but sits last because it touches the composer every other | |
| 310 | merge request also touches and is cheaper once they have landed. 7 through 12 | |
| 311 | are repository and account administration, which is the part of the surface a | |
| 312 | phone is least likely to be the right tool for. If the plan is cut, it is cut | |
| 313 | after 6, with 13 pulled forward. | |
gitbay/ContentView.swift +2
| @@ -98,6 +98,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 98 | 98 | RefsView(client: client, repo: repo) |
| 99 | 99 | case .milestones(let repo): |
| 100 | 100 | MilestoneListView(client: client, repo: repo) |
| 101 | case .labels(let repo): | |
| 102 | LabelListView(client: client, repo: repo) | |
| 101 | 103 | case .wiki(let repo): |
| 102 | 104 | WikiView(client: client, repo: repo) |
| 103 | 105 | case .wikiPage(let repo, let page): |
gitbay/Issues/IssueDetailViewModel.swift +12
| @@ -12,6 +12,10 @@ final class IssueDetailViewModel { | ||
| 12 | 12 | private(set) var working = false |
| 13 | 13 | /// Open milestones for the picker; fetched on first use. |
| 14 | 14 | 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 | |
| 15 | 19 | |
| 16 | 20 | private let client: GitbayClient |
| 17 | 21 | let repoPath: String |
| @@ -30,7 +34,15 @@ final class IssueDetailViewModel { | ||
| 30 | 34 | state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self)) |
| 31 | 35 | } catch { |
| 32 | 36 | state = .from(error) |
| 37 | return | |
| 33 | 38 | } |
| 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)) ?? [] | |
| 34 | 46 | } |
| 35 | 47 | |
| 36 | 48 | /// Both fields are always sent: the title as given, the body over |
gitbay/Issues/IssueLabel.swift added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | import 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. | |
| 8 | nonisolated 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. | |
| 21 | nonisolated 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. | |
| 94 | nonisolated 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 { | ||
| 13 | 13 | |
| 14 | 14 | let list: PagedListModel<Issue> |
| 15 | 15 | let repoPath: String |
| 16 | private let client: GitbayClient | |
| 16 | 17 | var filter: StateFilter = .open { |
| 17 | 18 | didSet { |
| 18 | 19 | guard filter != oldValue else { return } |
| @@ -21,7 +22,17 @@ final class IssueListViewModel { | ||
| 21 | 22 | } |
| 22 | 23 | } |
| 23 | 24 | |
| 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 | ||
| 24 | 34 | init(client: GitbayClient, repoPath: String) { |
| 35 | self.client = client | |
| 25 | 36 | self.repoPath = repoPath |
| 26 | 37 | list = PagedListModel( |
| 27 | 38 | client: client, |
| @@ -34,6 +45,15 @@ final class IssueListViewModel { | ||
| 34 | 45 | |
| 35 | 46 | func load() async { |
| 36 | 47 | 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)) ?? [] | |
| 37 | 57 | } |
| 38 | 58 | |
| 39 | 59 | private func configureList() { |
gitbay/Issues/LabelListViewModel.swift added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 9 | final 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 { | ||
| 26 | 26 | |
| 27 | 27 | ForEach(model.state.value ?? []) { issue in |
| 28 | 28 | NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) { |
| 29 | IssueRow(issue: issue) | |
| 29 | IssueRow(issue: issue, colors: model.colors) | |
| 30 | 30 | } |
| 31 | 31 | } |
| 32 | 32 | PageFooter(list: model.list) |
| @@ -70,6 +70,7 @@ struct IssueListView: View { | ||
| 70 | 70 | |
| 71 | 71 | private struct IssueRow: View { |
| 72 | 72 | let issue: Issue |
| 73 | let colors: LabelColors | |
| 73 | 74 | |
| 74 | 75 | var body: some View { |
| 75 | 76 | VStack(alignment: .leading, spacing: 4) { |
| @@ -86,7 +87,7 @@ private struct IssueRow: View { | ||
| 86 | 87 | .font(.gbSans(.caption2)) |
| 87 | 88 | .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) |
| 88 | 89 | ForEach(issue.labels ?? [], id: \.self) { label in |
| 89 | GBChip(label, .secondary) | |
| 90 | GBChip(label, colors.color(label)) | |
| 90 | 91 | } |
| 91 | 92 | Spacer() |
| 92 | 93 | if let assignees = issue.assignees, !assignees.isEmpty { |
gitbay/Views/Issues/IssueView.swift +8 −4
| @@ -88,7 +88,7 @@ struct IssueView: View { | ||
| 88 | 88 | @ViewBuilder |
| 89 | 89 | private func triageSection(_ issue: IssueDetail) -> some View { |
| 90 | 90 | Section("Labels") { |
| 91 | labelFlow(issue.labels ?? [], remove: { label in | |
| 91 | labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in | |
| 92 | 92 | Task { await model.removeLabel(label) } |
| 93 | 93 | }) |
| 94 | 94 | HStack { |
| @@ -150,7 +150,11 @@ struct IssueView: View { | ||
| 150 | 150 | } |
| 151 | 151 | |
| 152 | 152 | @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 { | |
| 154 | 158 | if !items.isEmpty { |
| 155 | 159 | ScrollView(.horizontal, showsIndicators: false) { |
| 156 | 160 | HStack(spacing: 6) { |
| @@ -168,8 +172,8 @@ struct IssueView: View { | ||
| 168 | 172 | .font(.gbSans(.caption)) |
| 169 | 173 | .padding(.horizontal, 8) |
| 170 | 174 | .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)) | |
| 173 | 177 | } |
| 174 | 178 | } |
| 175 | 179 | } |
gitbay/Views/Issues/LabelListView.swift added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// A repository's labels: their colour, how many issues carry each, and | |
| 4 | /// the controls to create, recolour and remove them. | |
| 5 | struct 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 | ||
| 76 | private 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 { | ||
| 12 | 12 | case blame(repo: String, path: String, ref: String?) |
| 13 | 13 | case refs(repo: String) |
| 14 | 14 | case milestones(repo: String) |
| 15 | case labels(repo: String) | |
| 15 | 16 | case wiki(repo: String) |
| 16 | 17 | case wikiPage(repo: String, page: String) |
| 17 | 18 | case commit(repo: String, sha: String) |
gitbay/Views/Repos/RepoView.swift +3
| @@ -43,6 +43,9 @@ struct RepoView: View { | ||
| 43 | 43 | NavigationLink(value: RepoRoute.milestones(repo: path)) { |
| 44 | 44 | Label("Milestones", systemImage: "flag") |
| 45 | 45 | } |
| 46 | NavigationLink(value: RepoRoute.labels(repo: path)) { | |
| 47 | Label("Labels", systemImage: "tag") | |
| 48 | } | |
| 46 | 49 | NavigationLink(value: BuildRoute.list(repo: path)) { |
| 47 | 50 | Label("Builds", systemImage: "hammer") |
| 48 | 51 | } |
gitbayTests/IssueBuildViewModelTests.swift +1 −1
| @@ -84,7 +84,7 @@ struct IssueDetailViewModelTests { | ||
| 84 | 84 | |
| 85 | 85 | await model.comment("triaged from the phone") |
| 86 | 86 | |
| 87 | let write = stub.seen[1] | |
| 87 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 88 | 88 | #expect(write.method == "POST") |
| 89 | 89 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 90 | 90 | #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"]) |
gitbayTests/LabelTests.swift added +352
| @@ -0,0 +1,352 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | struct 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 | ||
| 60 | struct 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 | ||
| 84 | struct 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 | ||
| 102 | private 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 | ||
| 112 | private 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 | ||
| 117 | private 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 | """ | |
| 122 | private 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 | |
| 129 | struct 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 | |
| 168 | struct 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 | |
| 238 | struct 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 | } | |