Commit a6f707b7db
Verified · cmc
Layout: unified · split
docs/superpowers/plans/2026-09-22-1.4.0-builds-and-mr-labels.md added +1257
| @@ -0,0 +1,1257 @@ | ||
| 1 | # 1.4.0 Build List and MR Labels 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:** The build list names each build's commit and pages past the first window; merge requests carry labels and filter by them, as issues already do. | |
| 6 | ||
| 7 | **Architecture:** `Build` decodes the `subject` that `build list` has returned since gitbay v1.33.0, and `BuildListViewModel` moves onto the `PagedListModel` every other list uses. MR labels copy the issue implementation: `mr label --add/--remove` on the detail screen, `mr list --label` from the filter sheet. The chip row and the label picker the issue screens own become shared views so both screens use one copy. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. | |
| 10 | ||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.4.0: build list and MR labels") | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | |
| 16 | - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. | |
| 17 | - **Never push to `main`. Never merge.** Two branches, one MR each: `build-list` (Tasks 1–3) and `mr-labels` (Tasks 4–6), both cut from `main` after the 1.3.0 work has merged. Task 7 is the version bump and waits for the user to merge both. | |
| 18 | - **Swift 6, default `MainActor` isolation.** Wire models are `nonisolated` structs. | |
| 19 | - **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 7. | |
| 20 | - **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** A stub's `match:` is a URL substring. Assert writes by filtering `stub.seen` on `method == "POST"`, never by position. | |
| 21 | - **A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`.** `next` is absent on the last page. `PagedListModel` appends `--limit <n>` and, for later pages, `--cursor <c>` to its `argv`. | |
| 22 | - **Server order, never a client sort.** A client sort reorders rows across page boundaries. | |
| 23 | - **`MRDetailViewModel.load()` must keep issuing exactly four GETs** (`mr show`, `mr diff`, `mr threads`, `mr revisions`). `MRViewModelTests` and `MRLifecycleTests` count them. Labels load from the section's `.task`, the way milestones already do. | |
| 24 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | |
| 25 | - **Run the whole unit target, and read the count.** `-only-testing:` on one test can report success with zero tests run. | |
| 26 | ||
| 27 | ```bash | |
| 28 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 29 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | |
| 30 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | |
| 31 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | |
| 32 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | |
| 33 | ``` | |
| 34 | ||
| 35 | "Run the unit target" below means these two commands. Record the count printed before Task 1 as `BASE`; expected counts below are relative to it. | |
| 36 | ||
| 37 | --- | |
| 38 | ||
| 39 | ### Task 1: A build row names its commit | |
| 40 | ||
| 41 | **Files:** | |
| 42 | - Modify: `gitbay/Builds/BuildModels.swift:4-22` (`Build`) | |
| 43 | - Modify: `gitbay/Views/Builds/BuildListView.swift:69-99` (`BuildRow`) | |
| 44 | - Create: `gitbayTests/BuildListPagingTests.swift` | |
| 45 | ||
| 46 | **Interfaces:** | |
| 47 | - Produces: `Build.subject: String?`, `Build.title: String` (`"#<number> <subject>"`, or `"#<number> <shortSHA>"` when there is no subject). | |
| 48 | ||
| 49 | - [ ] **Step 1: Branch** | |
| 50 | ||
| 51 | ```bash | |
| 52 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c build-list | |
| 53 | ``` | |
| 54 | ||
| 55 | Run the unit target and record the count as `BASE`. | |
| 56 | ||
| 57 | - [ ] **Step 2: Write the failing tests** | |
| 58 | ||
| 59 | Create `gitbayTests/BuildListPagingTests.swift`: | |
| 60 | ||
| 61 | ```swift | |
| 62 | import Foundation | |
| 63 | import Testing | |
| 64 | @testable import gitbay | |
| 65 | ||
| 66 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 67 | let box = StubProtocol.box() | |
| 68 | let client = GitbayClient( | |
| 69 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 70 | token: "test-token", | |
| 71 | session: box.session() | |
| 72 | ) | |
| 73 | return (client, box) | |
| 74 | } | |
| 75 | ||
| 76 | private func buildRow(_ number: Int, subject: String? = nil) -> String { | |
| 77 | let subjectField = subject.map { #","subject":"\#($0)""# } ?? "" | |
| 78 | return """ | |
| 79 | {"number":\(number),"job":"test","status":"success",\ | |
| 80 | "sha":"db7503f06f11c31a41f1c8e91187e1ff8f861c7f","ref":"main",\ | |
| 81 | "created_at":"2026-09-22T04:40:16.000Z"\(subjectField)} | |
| 82 | """ | |
| 83 | } | |
| 84 | ||
| 85 | struct BuildSubjectTests { | |
| 86 | ||
| 87 | private func decode(_ json: String) throws -> Build { | |
| 88 | try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8)) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func aRowLeadsWithTheCommitSubject() throws { | |
| 92 | let build = try decode(buildRow(1479, subject: "CHANGELOG: v1.34.1")) | |
| 93 | #expect(build.subject == "CHANGELOG: v1.34.1") | |
| 94 | #expect(build.title == "#1479 CHANGELOG: v1.34.1") | |
| 95 | } | |
| 96 | ||
| 97 | /// A build whose commit is gone has no subject to report; the sha is | |
| 98 | /// all there is. | |
| 99 | @Test func aRowWithoutASubjectFallsBackToTheSha() throws { | |
| 100 | let build = try decode(buildRow(12)) | |
| 101 | #expect(build.subject == nil) | |
| 102 | #expect(build.title == "#12 db7503f06f") | |
| 103 | } | |
| 104 | ||
| 105 | @Test func anEmptySubjectAlsoFallsBack() throws { | |
| 106 | let build = try decode(buildRow(12, subject: "")) | |
| 107 | #expect(build.title == "#12 db7503f06f") | |
| 108 | } | |
| 109 | } | |
| 110 | ``` | |
| 111 | ||
| 112 | - [ ] **Step 3: Run the unit target to verify it fails** | |
| 113 | ||
| 114 | Expected: build FAILS with `value of type 'Build' has no member 'subject'` and `… no member 'title'`. | |
| 115 | ||
| 116 | - [ ] **Step 4: Decode `subject` and derive the title** | |
| 117 | ||
| 118 | In `gitbay/Builds/BuildModels.swift`, in `Build`, add after `let finishedAt: Date?`: | |
| 119 | ||
| 120 | ```swift | |
| 121 | /// The built commit's subject, which `build list` resolves since | |
| 122 | /// gitbay v1.33.0. Absent when the commit is gone. | |
| 123 | let subject: String? | |
| 124 | ``` | |
| 125 | ||
| 126 | Change `case number, job, status, sha, ref` to `case number, job, status, sha, ref, subject`. | |
| 127 | ||
| 128 | Add after `var shortSHA`: | |
| 129 | ||
| 130 | ```swift | |
| 131 | /// What a row leads with. Recognising a build by its sha meant | |
| 132 | /// opening the commit or already knowing it (krz/gitbay#241). | |
| 133 | var title: String { | |
| 134 | if let subject, !subject.isEmpty { return "#\(number) \(subject)" } | |
| 135 | return "#\(number) \(shortSHA)" | |
| 136 | } | |
| 137 | ``` | |
| 138 | ||
| 139 | - [ ] **Step 5: Lead the row with it** | |
| 140 | ||
| 141 | In `gitbay/Views/Builds/BuildListView.swift`, in `BuildRow`, replace | |
| 142 | ||
| 143 | ```swift | |
| 144 | Text("#\(build.number) \(build.job)") | |
| 145 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 146 | HStack(spacing: 6) { | |
| 147 | Text(build.ref) | |
| 148 | Text(build.shortSHA) | |
| 149 | .font(.gbMono(.caption)) | |
| 150 | } | |
| 151 | ``` | |
| 152 | ||
| 153 | with | |
| 154 | ||
| 155 | ```swift | |
| 156 | Text(build.title) | |
| 157 | .font(.gbSans(.subheadline).weight(.medium)) | |
| 158 | .lineLimit(2) | |
| 159 | HStack(spacing: 6) { | |
| 160 | Text(build.job) | |
| 161 | Text(build.ref) | |
| 162 | Text(build.shortSHA) | |
| 163 | .font(.gbMono(.caption)) | |
| 164 | } | |
| 165 | ``` | |
| 166 | ||
| 167 | - [ ] **Step 6: Run the unit target to verify it passes** | |
| 168 | ||
| 169 | Expected: `Passed <BASE + 3> 0`. | |
| 170 | ||
| 171 | - [ ] **Step 7: Commit** | |
| 172 | ||
| 173 | ```bash | |
| 174 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildModels.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift && git commit -m "builds: lead each row with the commit subject | |
| 175 | ||
| 176 | build list returns subject since gitbay v1.33.0 (krz/gitbay#241). The | |
| 177 | job, ref and sha move to the meta line." | |
| 178 | ``` | |
| 179 | ||
| 180 | --- | |
| 181 | ||
| 182 | ### Task 2: The build list pages | |
| 183 | ||
| 184 | **Files:** | |
| 185 | - Modify: `gitbay/Builds/BuildListViewModel.swift` (whole file) | |
| 186 | - Modify: `gitbay/Views/Builds/BuildListView.swift:19-23` (add `PageFooter`) | |
| 187 | - Modify: `gitbayTests/BuildListPagingTests.swift` (add `BuildListPagingTests`) | |
| 188 | - Modify: `gitbayTests/IssueBuildViewModelTests.swift:32-40` (`buildListJSON`), `:146-193` (`BuildListViewModelTests`) | |
| 189 | - Modify: `gitbayTests/RepoManagementTests.swift:367-371` (`BuildTriggerTests.buildListJSON`) | |
| 190 | ||
| 191 | **Interfaces:** | |
| 192 | - Consumes: `PagedListModel<Element>(client:argv:emptyMessage:pageSize:)`, its `reload()`, `loadMore()`, `hasMore`, `argv`, `emptyMessage`, `state`; `PageFooter(list:)`. | |
| 193 | - Produces: `BuildListViewModel.list: PagedListModel<Build>`; `state`, `filter`, `jobs`, `load()`, `loadJobs()`, `trigger(job:)` keep their signatures. | |
| 194 | ||
| 195 | - [ ] **Step 1: Write the failing tests** | |
| 196 | ||
| 197 | Append to `gitbayTests/BuildListPagingTests.swift`: | |
| 198 | ||
| 199 | ```swift | |
| 200 | private func page(_ rows: [String], next: String? = nil) -> String { | |
| 201 | let nextField = next.map { #","next":"\#($0)""# } ?? "" | |
| 202 | return #"{"protocol_version":1,"data":{"items":["# + rows.joined(separator: ",") | |
| 203 | + "]" + nextField + #"},"exit_code":0}"# | |
| 204 | } | |
| 205 | ||
| 206 | @MainActor | |
| 207 | struct BuildListPagingTests { | |
| 208 | ||
| 209 | /// The web asks for thirty; so does the app. | |
| 210 | @Test func asksForThirtyAndFollowsTheCursor() async throws { | |
| 211 | let (client, stub) = try makeClient() | |
| 212 | stub.enqueue(.init(status: 200, json: page([buildRow(5), buildRow(4)], next: "YnVpbGQ6NA"), | |
| 213 | match: "argv=build&argv=list")) | |
| 214 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | |
| 215 | ||
| 216 | await model.load() | |
| 217 | ||
| 218 | #expect(try #require(model.state.value).map(\.number) == [5, 4]) | |
| 219 | #expect(model.list.hasMore) | |
| 220 | let first = try #require(stub.seen.first) | |
| 221 | #expect(first.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--limit&argv=30") | |
| 222 | ||
| 223 | stub.enqueue(.init(status: 200, json: page([buildRow(3)]), match: "argv=build&argv=list")) | |
| 224 | await model.list.loadMore() | |
| 225 | ||
| 226 | #expect(try #require(model.state.value).map(\.number) == [5, 4, 3]) | |
| 227 | #expect(model.list.hasMore == false) | |
| 228 | let second = try #require(stub.seen.last) | |
| 229 | #expect(second.url.query()?.hasSuffix("argv=--limit&argv=30&argv=--cursor&argv=YnVpbGQ6NA") == true) | |
| 230 | } | |
| 231 | ||
| 232 | /// Rows are shown in the order the server sent them. A client sort | |
| 233 | /// would reorder rows across a page boundary. | |
| 234 | @Test func keepsTheServersOrder() async throws { | |
| 235 | let (client, stub) = try makeClient() | |
| 236 | stub.enqueue(.init(status: 200, json: page([buildRow(3), buildRow(4)]))) | |
| 237 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | |
| 238 | ||
| 239 | await model.load() | |
| 240 | ||
| 241 | #expect(try #require(model.state.value).map(\.number) == [3, 4]) | |
| 242 | } | |
| 243 | ||
| 244 | @Test func aFilterChangeStartsOverFromTheFirstPage() async throws { | |
| 245 | let (client, stub) = try makeClient() | |
| 246 | stub.enqueue(.init(status: 200, json: page([buildRow(5)], next: "YnVpbGQ6NQ"))) | |
| 247 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | |
| 248 | await model.load() | |
| 249 | ||
| 250 | stub.enqueue(.init(status: 200, json: page([]))) | |
| 251 | var filter = BuildFilter() | |
| 252 | filter.status = .failure | |
| 253 | model.filter = filter | |
| 254 | await until { | |
| 255 | if case .empty = model.state { return true } | |
| 256 | return false | |
| 257 | } | |
| 258 | ||
| 259 | let second = try #require(stub.seen.last) | |
| 260 | #expect(second.url.query() | |
| 261 | == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--limit&argv=30") | |
| 262 | #expect(model.list.hasMore == false) | |
| 263 | } | |
| 264 | } | |
| 265 | ``` | |
| 266 | ||
| 267 | In `gitbayTests/IssueBuildViewModelTests.swift`, replace `buildListJSON` with the paged shape, keeping the rows in the order the server would send them (newest first): | |
| 268 | ||
| 269 | ```swift | |
| 270 | private let buildListJSON = """ | |
| 271 | {"protocol_version":1,"data":{"items":[\ | |
| 272 | {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ | |
| 273 | "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"},\ | |
| 274 | {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ | |
| 275 | "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ | |
| 276 | "finished_at":"2026-08-20T10:05:00.000Z"}\ | |
| 277 | ]},"exit_code":0} | |
| 278 | """ | |
| 279 | ``` | |
| 280 | ||
| 281 | In `BuildListViewModelTests`, rename `listsNewestFirst()` to `decodesEachRow()` (its assertions stay: `[4, 3]`, `running`, `finishedAt`). In `changingTheFilterReloadsWithItsFlags()`, change the empty stub to | |
| 282 | ||
| 283 | ```swift | |
| 284 | stub.enqueue(.init(status: 200, json: """ | |
| 285 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | |
| 286 | """)) | |
| 287 | ``` | |
| 288 | ||
| 289 | and the expected query to | |
| 290 | ||
| 291 | ```swift | |
| 292 | == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test&argv=--limit&argv=30") | |
| 293 | ``` | |
| 294 | ||
| 295 | In `gitbayTests/RepoManagementTests.swift`, in `BuildTriggerTests`, replace `buildListJSON` with: | |
| 296 | ||
| 297 | ```swift | |
| 298 | private let buildListJSON = """ | |
| 299 | {"protocol_version":1,"data":{"items":[\ | |
| 300 | {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ | |
| 301 | "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}]},"exit_code":0} | |
| 302 | """ | |
| 303 | ``` | |
| 304 | ||
| 305 | - [ ] **Step 2: Run the unit target to verify it fails** | |
| 306 | ||
| 307 | Expected: build FAILS with `value of type 'BuildListViewModel' has no member 'list'`. | |
| 308 | ||
| 309 | - [ ] **Step 3: Move the view model onto `PagedListModel`** | |
| 310 | ||
| 311 | Replace the whole of `gitbay/Builds/BuildListViewModel.swift` with: | |
| 312 | ||
| 313 | ```swift | |
| 314 | import Foundation | |
| 315 | import Observation | |
| 316 | ||
| 317 | /// `build list <repo>`, narrowed by `filter`, thirty to a page as the web | |
| 318 | /// pages it. Rows stay in server order: a client sort would reorder them | |
| 319 | /// across a page boundary. | |
| 320 | @Observable | |
| 321 | @MainActor | |
| 322 | final class BuildListViewModel { | |
| 323 | ||
| 324 | let list: PagedListModel<Build> | |
| 325 | /// A change reloads from the first page. The sheet edits a copy and | |
| 326 | /// assigns once, so a typed branch does not fire a request per | |
| 327 | /// keystroke. | |
| 328 | var filter = BuildFilter() { | |
| 329 | didSet { | |
| 330 | guard filter != oldValue else { return } | |
| 331 | list.argv = ["build", "list", repoPath] + filter.flags() | |
| 332 | list.emptyMessage = filter.emptyMessage | |
| 333 | reloadTask?.cancel() | |
| 334 | reloadTask = Task { await list.reload() } | |
| 335 | } | |
| 336 | } | |
| 337 | private var reloadTask: Task<Void, Never>? | |
| 338 | /// The jobs a trigger can name. Empty when the repo has no CI | |
| 339 | /// config — the web hides its trigger form in that case. | |
| 340 | private(set) var jobs: [CIJob] = [] | |
| 341 | private(set) var actionError: String? | |
| 342 | private(set) var working = false | |
| 343 | ||
| 344 | private let client: GitbayClient | |
| 345 | let repoPath: String | |
| 346 | ||
| 347 | init(client: GitbayClient, repoPath: String) { | |
| 348 | self.client = client | |
| 349 | self.repoPath = repoPath | |
| 350 | list = PagedListModel( | |
| 351 | client: client, | |
| 352 | argv: ["build", "list", repoPath] + BuildFilter().flags(), | |
| 353 | emptyMessage: BuildFilter().emptyMessage, | |
| 354 | pageSize: 30 | |
| 355 | ) | |
| 356 | } | |
| 357 | ||
| 358 | var state: LoadState<[Build]> { list.state } | |
| 359 | ||
| 360 | func load() async { | |
| 361 | reloadTask?.cancel() | |
| 362 | await list.reload() | |
| 363 | } | |
| 364 | ||
| 365 | /// `build jobs <owner/name>` — what the picker offers. A repo | |
| 366 | /// without a CI config answers "not found"; that means no jobs, not | |
| 367 | /// a failure worth showing. | |
| 368 | func loadJobs() async { | |
| 369 | jobs = (try? await client.readList(["build", "jobs", repoPath], of: CIJob.self)) ?? [] | |
| 370 | } | |
| 371 | ||
| 372 | /// `build trigger <owner/name> <job>` — queue a job now. | |
| 373 | func trigger(job: String) async { | |
| 374 | working = true | |
| 375 | actionError = nil | |
| 376 | defer { working = false } | |
| 377 | do { | |
| 378 | try await client.run(["build", "trigger", repoPath, job]) | |
| 379 | await load() | |
| 380 | } catch let error as GitbayError { | |
| 381 | actionError = error.userFacingMessage | |
| 382 | } catch { | |
| 383 | actionError = GitbayError.transport(error).userFacingMessage | |
| 384 | } | |
| 385 | } | |
| 386 | } | |
| 387 | ``` | |
| 388 | ||
| 389 | - [ ] **Step 4: Page from the list's last row** | |
| 390 | ||
| 391 | In `gitbay/Views/Builds/BuildListView.swift`, after the `ForEach(model.state.value ?? []) { … }` block inside the `List`, add: | |
| 392 | ||
| 393 | ```swift | |
| 394 | PageFooter(list: model.list) | |
| 395 | ``` | |
| 396 | ||
| 397 | - [ ] **Step 5: Run the unit target to verify it passes** | |
| 398 | ||
| 399 | Expected: `Passed <BASE + 6> 0`. | |
| 400 | ||
| 401 | - [ ] **Step 6: Commit** | |
| 402 | ||
| 403 | ```bash | |
| 404 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildListViewModel.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift gitbayTests/IssueBuildViewModelTests.swift gitbayTests/RepoManagementTests.swift && git commit -m "builds: page the list thirty at a time | |
| 405 | ||
| 406 | build list takes --limit and --cursor since gitbay v1.33.0 | |
| 407 | (krz/gitbay#244). The list moves onto PagedListModel and keeps server | |
| 408 | order." | |
| 409 | ``` | |
| 410 | ||
| 411 | --- | |
| 412 | ||
| 413 | ### Task 3: Live steps for the build list, then the MR | |
| 414 | ||
| 415 | **Files:** | |
| 416 | - Modify: `gitbayUITests/LiveSmokeUITests.swift:1760-1766` (`testBuildFilterFlow`, the job step) and a new `testBuildPagingFlow()` after `testBuildFilterFlow()` | |
| 417 | ||
| 418 | **Interfaces:** | |
| 419 | - Consumes: `readCommand(_:)`, `openRepo(_:)`, `scrollTo(_:swipes:)` in the live suite. | |
| 420 | ||
| 421 | - [ ] **Step 1: The job now has its own text** | |
| 422 | ||
| 423 | In `testBuildFilterFlow()`, replace | |
| 424 | ||
| 425 | ```swift | |
| 426 | XCTAssertTrue(app.staticTexts | |
| 427 | .containing(NSPredicate(format: "label CONTAINS ' test'")).firstMatch | |
| 428 | .waitForExistence(timeout: 20), "job filter did not narrow to test") | |
| 429 | ``` | |
| 430 | ||
| 431 | with | |
| 432 | ||
| 433 | ```swift | |
| 434 | XCTAssertTrue(app.staticTexts["test"].firstMatch | |
| 435 | .waitForExistence(timeout: 20), "job filter did not narrow to test") | |
| 436 | ``` | |
| 437 | ||
| 438 | - [ ] **Step 2: Add the paging test** | |
| 439 | ||
| 440 | After `testBuildFilterFlow()`, add: | |
| 441 | ||
| 442 | ```swift | |
| 443 | /// krz/gitbay has well over thirty builds, so the thirty-first is on | |
| 444 | /// the second page. Reaching it means the footer asked for more. | |
| 445 | func testBuildPagingFlow() throws { | |
| 446 | guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]), | |
| 447 | let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | |
| 448 | let page = envelope["data"] as? [String: Any], | |
| 449 | let items = page["items"] as? [[String: Any]], items.count == 31, | |
| 450 | let number = items[30]["number"] as? Int | |
| 451 | else { throw XCTSkip("could not read krz/gitbay's builds over the API") } | |
| 452 | ||
| 453 | openRepo("krz/gitbay") | |
| 454 | app.staticTexts["Builds"].firstMatch.tap() | |
| 455 | XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") | |
| 456 | let thirtyFirst = app.staticTexts | |
| 457 | .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch | |
| 458 | XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20), | |
| 459 | "build #\(number), the first row of page two, never appeared") | |
| 460 | } | |
| 461 | ``` | |
| 462 | ||
| 463 | - [ ] **Step 3: Build the UI test target** | |
| 464 | ||
| 465 | ```bash | |
| 466 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 467 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 | |
| 468 | ``` | |
| 469 | ||
| 470 | Expected: `** TEST BUILD SUCCEEDED **`. | |
| 471 | ||
| 472 | - [ ] **Step 4: Run the two live tests (needs a token from the user)** | |
| 473 | ||
| 474 | Ask the user for a token for `ios-smoke` (`ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d`). `testBuildFilterFlow` fails when krz/gitbay has a build pending or running; wait until `gitbay build list krz/gitbay --status running --json` and `--status pending` both return no rows. | |
| 475 | ||
| 476 | ```bash | |
| 477 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | |
| 478 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | |
| 479 | -only-testing:gitbayUITests/LiveSmokeUITests/testBuildFilterFlow \ | |
| 480 | -only-testing:gitbayUITests/LiveSmokeUITests/testBuildPagingFlow \ | |
| 481 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | |
| 482 | ``` | |
| 483 | ||
| 484 | Expected: both `passed`. Read failures from the bundle, not stdout. If no token is available, record that the live steps were not run. | |
| 485 | ||
| 486 | - [ ] **Step 5: Commit, push, open the MR** | |
| 487 | ||
| 488 | ```bash | |
| 489 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: build rows by subject, and the second page" && \ | |
| 490 | git push -u origin build-list && gitbay mr create --source build-list --target main \ | |
| 491 | --title "Build list: name the commit, page past thirty" --file - <<'EOF' | |
| 492 | - Build decodes subject (gitbay v1.33.0, krz/gitbay#241). A row leads | |
| 493 | with "#<n> <subject>", falling back to the sha when the commit is | |
| 494 | gone; job, ref and sha move to the meta line. | |
| 495 | - The list moves onto PagedListModel at thirty a page (krz/gitbay#244) | |
| 496 | and keeps server order. | |
| 497 | ||
| 498 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | |
| 499 | EOF | |
| 500 | ``` | |
| 501 | ||
| 502 | --- | |
| 503 | ||
| 504 | ### Task 4: Shared chip row and label picker | |
| 505 | ||
| 506 | A refactor with no behaviour change, so the merge request screens in Tasks 5 and 6 use the issue screens' views rather than a copy. | |
| 507 | ||
| 508 | **Files:** | |
| 509 | - Create: `gitbay/Views/Shared/RemovableChips.swift` | |
| 510 | - Modify: `gitbay/Views/Issues/IssueView.swift:93-96`, `:112-114` (call sites), `:155-181` (delete `labelFlow`) | |
| 511 | - Modify: `gitbay/Views/Shared/FilterSheet.swift:1-6` (doc comment), `:60-98` (`IssueFilterExtra` → `LabelFilterSection` + assignee) | |
| 512 | ||
| 513 | **Interfaces:** | |
| 514 | - Produces: `RemovableChips(items: [String], idPrefix: String, disabled: Bool, color: (String) -> Color = { _ in .secondary }, remove: (String) -> Void)`; `LabelFilterSection(label: Binding<String?>, labels: [IssueLabel], colors: LabelColors)`. `IssueFilterExtra`'s initializer is unchanged. | |
| 515 | ||
| 516 | - [ ] **Step 1: Branch** | |
| 517 | ||
| 518 | ```bash | |
| 519 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c mr-labels | |
| 520 | ``` | |
| 521 | ||
| 522 | - [ ] **Step 2: Create the chip row** | |
| 523 | ||
| 524 | Create `gitbay/Views/Shared/RemovableChips.swift`: | |
| 525 | ||
| 526 | ```swift | |
| 527 | import SwiftUI | |
| 528 | ||
| 529 | /// A horizontal row of chips, each with a remove button: an issue's | |
| 530 | /// labels and assignees, a merge request's labels. The remove button is | |
| 531 | /// identified as `<idPrefix>-remove-<item>`. | |
| 532 | struct RemovableChips: View { | |
| 533 | ||
| 534 | let items: [String] | |
| 535 | let idPrefix: String | |
| 536 | let disabled: Bool | |
| 537 | var color: (String) -> Color = { _ in .secondary } | |
| 538 | let remove: (String) -> Void | |
| 539 | ||
| 540 | var body: some View { | |
| 541 | if !items.isEmpty { | |
| 542 | ScrollView(.horizontal, showsIndicators: false) { | |
| 543 | HStack(spacing: 6) { | |
| 544 | ForEach(items, id: \.self) { item in | |
| 545 | HStack(spacing: 3) { | |
| 546 | Text(item) | |
| 547 | Button { | |
| 548 | remove(item) | |
| 549 | } label: { | |
| 550 | Image(systemName: "xmark.circle.fill") | |
| 551 | .foregroundStyle(.tertiary) | |
| 552 | } | |
| 553 | .disabled(disabled) | |
| 554 | .accessibilityIdentifier("\(idPrefix)-remove-\(item)") | |
| 555 | } | |
| 556 | .font(.gbSans(.caption)) | |
| 557 | .padding(.horizontal, 8) | |
| 558 | .padding(.vertical, 3) | |
| 559 | .background(color(item).opacity(0.07), in: gbChipShape) | |
| 560 | .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1)) | |
| 561 | } | |
| 562 | } | |
| 563 | } | |
| 564 | } | |
| 565 | } | |
| 566 | } | |
| 567 | ``` | |
| 568 | ||
| 569 | - [ ] **Step 3: Use it in the issue screen** | |
| 570 | ||
| 571 | In `gitbay/Views/Issues/IssueView.swift`, replace | |
| 572 | ||
| 573 | ```swift | |
| 574 | labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in | |
| 575 | Task { await model.removeLabel(label) } | |
| 576 | }) | |
| 577 | ``` | |
| 578 | ||
| 579 | with | |
| 580 | ||
| 581 | ```swift | |
| 582 | RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label", | |
| 583 | disabled: model.working, color: { model.colors.color($0) }, | |
| 584 | remove: { label in Task { await model.removeLabel(label) } }) | |
| 585 | ``` | |
| 586 | ||
| 587 | replace | |
| 588 | ||
| 589 | ```swift | |
| 590 | labelFlow(issue.assignees ?? [], remove: { user in | |
| 591 | Task { await model.removeAssignee(user) } | |
| 592 | }) | |
| 593 | ``` | |
| 594 | ||
| 595 | with | |
| 596 | ||
| 597 | ```swift | |
| 598 | RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee", | |
| 599 | disabled: model.working, | |
| 600 | remove: { user in Task { await model.removeAssignee(user) } }) | |
| 601 | ``` | |
| 602 | ||
| 603 | and delete the whole `private func labelFlow(…)` function with its `@ViewBuilder` attribute. | |
| 604 | ||
| 605 | - [ ] **Step 4: Split the label picker out of `IssueFilterExtra`** | |
| 606 | ||
| 607 | In `gitbay/Views/Shared/FilterSheet.swift`, replace the doc comment on `FilterSheet` with: | |
| 608 | ||
| 609 | ```swift | |
| 610 | /// The issue and MR lists' filter sheet. A `Form` with the fields both | |
| 611 | /// lists share — author and milestone — plus an `extra` slot for the | |
| 612 | /// rows each list adds: the label picker on both, and an assignee field | |
| 613 | /// on issues only, since `mr list` takes no `--assignee`. | |
| 614 | ``` | |
| 615 | ||
| 616 | Replace the whole of `IssueFilterExtra` (the struct and its doc comment) with: | |
| 617 | ||
| 618 | ```swift | |
| 619 | /// The label picker both lists' sheets add through `FilterSheet`'s | |
| 620 | /// `extra` slot. | |
| 621 | struct LabelFilterSection: View { | |
| 622 | ||
| 623 | @Binding var label: String? | |
| 624 | let labels: [IssueLabel] | |
| 625 | let colors: LabelColors | |
| 626 | ||
| 627 | var body: some View { | |
| 628 | Section("Label") { | |
| 629 | Menu { | |
| 630 | Button("Any label") { label = nil } | |
| 631 | ForEach(labels) { entry in | |
| 632 | Button { | |
| 633 | label = entry.name | |
| 634 | } label: { | |
| 635 | Text(entry.name) | |
| 636 | } | |
| 637 | } | |
| 638 | } label: { | |
| 639 | HStack { | |
| 640 | if let label { | |
| 641 | GBChip(label, colors.color(label)) | |
| 642 | } else { | |
| 643 | Text("Any label") | |
| 644 | } | |
| 645 | Spacer() | |
| 646 | Image(systemName: "chevron.up.chevron.down") | |
| 647 | .font(.gbSans(.caption2)) | |
| 648 | .foregroundStyle(.secondary) | |
| 649 | } | |
| 650 | } | |
| 651 | .accessibilityIdentifier("filter-label-menu") | |
| 652 | } | |
| 653 | } | |
| 654 | } | |
| 655 | ||
| 656 | /// The label and assignee rows the issue list's sheet adds through | |
| 657 | /// `FilterSheet`'s `extra` slot. | |
| 658 | struct IssueFilterExtra: View { | |
| 659 | ||
| 660 | @Binding var label: String? | |
| 661 | @Binding var assignee: String? | |
| 662 | let labels: [IssueLabel] | |
| 663 | let colors: LabelColors | |
| 664 | ||
| 665 | var body: some View { | |
| 666 | LabelFilterSection(label: $label, labels: labels, colors: colors) | |
| 667 | Section("Assignee") { | |
| 668 | TextField("Username", text: $assignee.orEmpty) | |
| 669 | .autocorrectionDisabled() | |
| 670 | .textInputAutocapitalization(.never) | |
| 671 | .accessibilityIdentifier("filter-assignee") | |
| 672 | } | |
| 673 | } | |
| 674 | } | |
| 675 | ``` | |
| 676 | ||
| 677 | - [ ] **Step 5: Run the unit target** | |
| 678 | ||
| 679 | Expected: same count as before this task, 0 failed. Nothing here has a unit test; the issue screens' behaviour is unchanged. | |
| 680 | ||
| 681 | - [ ] **Step 6: Commit** | |
| 682 | ||
| 683 | ```bash | |
| 684 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/RemovableChips.swift gitbay/Views/Issues/IssueView.swift gitbay/Views/Shared/FilterSheet.swift && git commit -m "views: share the chip row and the label picker | |
| 685 | ||
| 686 | Merge requests are about to carry labels too; both screens use one | |
| 687 | copy." | |
| 688 | ``` | |
| 689 | ||
| 690 | --- | |
| 691 | ||
| 692 | ### Task 5: Label a merge request | |
| 693 | ||
| 694 | **Files:** | |
| 695 | - Modify: `gitbay/MRs/MRModels.swift:75-157` (`MRDetail`: add `labels`) | |
| 696 | - Modify: `gitbay/MRs/MRDetailViewModel.swift` (add `colors`, `loadLabels()`, `addLabel(_:)`, `removeLabel(_:)`) | |
| 697 | - Modify: `gitbay/Views/MRs/MRView.swift` (state `editingLabel`, `labelsSection`, call it after `milestoneSection(mr)`) | |
| 698 | - Create: `gitbayTests/MRLabelTests.swift` | |
| 699 | ||
| 700 | **Interfaces:** | |
| 701 | - Consumes: `RemovableChips` (Task 4), `IssueLabel`, `LabelColors`. | |
| 702 | - Produces: `MRDetail.labels: [String]`; `MRDetailViewModel.colors: LabelColors`, `loadLabels() async`, `addLabel(_ label: String) async`, `removeLabel(_ label: String) async`. | |
| 703 | ||
| 704 | - [ ] **Step 1: Write the failing tests** | |
| 705 | ||
| 706 | Create `gitbayTests/MRLabelTests.swift`: | |
| 707 | ||
| 708 | ```swift | |
| 709 | import Foundation | |
| 710 | import Testing | |
| 711 | @testable import gitbay | |
| 712 | ||
| 713 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 714 | let box = StubProtocol.box() | |
| 715 | let client = GitbayClient( | |
| 716 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 717 | token: "test-token", | |
| 718 | session: box.session() | |
| 719 | ) | |
| 720 | return (client, box) | |
| 721 | } | |
| 722 | ||
| 723 | private func mrShow(labels: String?) -> String { | |
| 724 | let field = labels.map { #","labels":\#($0)"# } ?? "" | |
| 725 | return """ | |
| 726 | {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ | |
| 727 | "source":"feature","target_ref":"main","head_sha":"aa",\ | |
| 728 | "created_at":"2026-09-22T04:40:16.000Z"\(field)},"exit_code":0} | |
| 729 | """ | |
| 730 | } | |
| 731 | ||
| 732 | private let okJSON = #"{"protocol_version":1,"exit_code":0}"# | |
| 733 | ||
| 734 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 735 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 736 | return try #require(body["argv"] as? [String]) | |
| 737 | } | |
| 738 | ||
| 739 | @MainActor | |
| 740 | struct MRLabelTests { | |
| 741 | ||
| 742 | @Test func mrShowCarriesItsLabels() async throws { | |
| 743 | let (client, stub) = try makeClient() | |
| 744 | stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug","ios"]"#), match: "argv=show")) | |
| 745 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 746 | ||
| 747 | await model.load() | |
| 748 | ||
| 749 | #expect(try #require(model.state.value).labels == ["bug", "ios"]) | |
| 750 | } | |
| 751 | ||
| 752 | /// The server omits the key when there are none. | |
| 753 | @Test func noLabelsKeyMeansNoLabels() async throws { | |
| 754 | let (client, stub) = try makeClient() | |
| 755 | stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) | |
| 756 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 757 | ||
| 758 | await model.load() | |
| 759 | ||
| 760 | #expect(try #require(model.state.value).labels.isEmpty) | |
| 761 | } | |
| 762 | ||
| 763 | @Test func addingAndRemovingSendMrLabel() async throws { | |
| 764 | let (client, stub) = try makeClient() | |
| 765 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 766 | ||
| 767 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 768 | stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug"]"#), match: "argv=show")) | |
| 769 | await model.addLabel("bug") | |
| 770 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 771 | stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) | |
| 772 | await model.removeLabel("bug") | |
| 773 | ||
| 774 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 775 | #expect(writes == [ | |
| 776 | ["mr", "label", "krz/gitbay", "7", "--add", "bug"], | |
| 777 | ["mr", "label", "krz/gitbay", "7", "--remove", "bug"], | |
| 778 | ]) | |
| 779 | #expect(model.actionError == nil) | |
| 780 | } | |
| 781 | ||
| 782 | /// Colours come from the repository's label set, read once. | |
| 783 | @Test func labelColoursAreReadOnce() async throws { | |
| 784 | let (client, stub) = try makeClient() | |
| 785 | stub.enqueue(.init(status: 200, json: """ | |
| 786 | {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2}],"exit_code":0} | |
| 787 | """, match: "argv=label&argv=list")) | |
| 788 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 789 | ||
| 790 | await model.loadLabels() | |
| 791 | await model.loadLabels() | |
| 792 | ||
| 793 | #expect(stub.seen.count == 1) | |
| 794 | #expect(stub.seen[0].url.query() == "argv=label&argv=list&argv=krz/gitbay") | |
| 795 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | |
| 796 | } | |
| 797 | } | |
| 798 | ``` | |
| 799 | ||
| 800 | - [ ] **Step 2: Run the unit target to verify it fails** | |
| 801 | ||
| 802 | Expected: build FAILS with `value of type 'MRDetail' has no member 'labels'` and `value of type 'MRDetailViewModel' has no member 'addLabel'` (and `removeLabel`, `loadLabels`, `colors`). | |
| 803 | ||
| 804 | - [ ] **Step 3: Decode `labels` on `MRDetail`** | |
| 805 | ||
| 806 | In `gitbay/MRs/MRModels.swift`, in `MRDetail`: | |
| 807 | ||
| 808 | After `let milestone: String?` add: | |
| 809 | ||
| 810 | ```swift | |
| 811 | /// Omitted by the server when there are none. | |
| 812 | let labels: [String] | |
| 813 | ``` | |
| 814 | ||
| 815 | In `CodingKeys`, change `case number, title, state, author, source, body, milestone, checks, …` to include `labels` after `milestone`: | |
| 816 | ||
| 817 | ```swift | |
| 818 | case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates | |
| 819 | ``` | |
| 820 | ||
| 821 | In `init(from:)`, after the `milestone = …` line add: | |
| 822 | ||
| 823 | ```swift | |
| 824 | labels = try container.decodeIfPresent([String].self, forKey: .labels) ?? [] | |
| 825 | ``` | |
| 826 | ||
| 827 | - [ ] **Step 4: Add the reads and writes to the view model** | |
| 828 | ||
| 829 | In `gitbay/MRs/MRDetailViewModel.swift`, after `setMilestone(_:)` add: | |
| 830 | ||
| 831 | ```swift | |
| 832 | /// The repository's labels, for chip colours. Read on first use from | |
| 833 | /// the Labels section rather than in `load()`, which stays at four | |
| 834 | /// reads. | |
| 835 | private(set) var colors = LabelColors() | |
| 836 | private var labelsLoaded = false | |
| 837 | ||
| 838 | func loadLabels() async { | |
| 839 | guard !labelsLoaded else { return } | |
| 840 | labelsLoaded = true | |
| 841 | colors = LabelColors( | |
| 842 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []) | |
| 843 | } | |
| 844 | ||
| 845 | /// A label that does not exist yet is created on the repository. | |
| 846 | func addLabel(_ label: String) async { | |
| 847 | await perform(["mr", "label"] + ref + ["--add", label]) | |
| 848 | } | |
| 849 | ||
| 850 | func removeLabel(_ label: String) async { | |
| 851 | await perform(["mr", "label"] + ref + ["--remove", label]) | |
| 852 | } | |
| 853 | ``` | |
| 854 | ||
| 855 | - [ ] **Step 5: Run the unit target to verify it passes** | |
| 856 | ||
| 857 | Expected: 4 more than before this task, 0 failed. `MRViewModelTests` and `MRLifecycleTests` still pass, since `load()` is unchanged. | |
| 858 | ||
| 859 | - [ ] **Step 6: Add the Labels section** | |
| 860 | ||
| 861 | In `gitbay/Views/MRs/MRView.swift`, add to the `@State` properties at the top: | |
| 862 | ||
| 863 | ```swift | |
| 864 | @State private var editingLabel = "" | |
| 865 | ``` | |
| 866 | ||
| 867 | Replace ` milestoneSection(mr)` in `body` with: | |
| 868 | ||
| 869 | ```swift | |
| 870 | milestoneSection(mr) | |
| 871 | labelsSection(mr) | |
| 872 | ``` | |
| 873 | ||
| 874 | Add after the `milestoneSection(_:)` function: | |
| 875 | ||
| 876 | ```swift | |
| 877 | private func labelsSection(_ mr: MRDetail) -> some View { | |
| 878 | Section("Labels") { | |
| 879 | RemovableChips(items: mr.labels, idPrefix: "mr-label", | |
| 880 | disabled: model.working, color: { model.colors.color($0) }, | |
| 881 | remove: { label in Task { await model.removeLabel(label) } }) | |
| 882 | HStack { | |
| 883 | TextField("Add label", text: $editingLabel) | |
| 884 | .autocorrectionDisabled() | |
| 885 | .textInputAutocapitalization(.never) | |
| 886 | .accessibilityIdentifier("mr-label-field") | |
| 887 | Button { | |
| 888 | let label = editingLabel.trimmingCharacters(in: .whitespaces) | |
| 889 | editingLabel = "" | |
| 890 | Task { await model.addLabel(label) } | |
| 891 | } label: { | |
| 892 | Image(systemName: "plus.circle.fill") | |
| 893 | } | |
| 894 | .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 895 | .accessibilityIdentifier("mr-label-add") | |
| 896 | } | |
| 897 | } | |
| 898 | .task { await model.loadLabels() } | |
| 899 | } | |
| 900 | ``` | |
| 901 | ||
| 902 | - [ ] **Step 7: Run the unit target** | |
| 903 | ||
| 904 | Expected: same count as Step 5, 0 failed. | |
| 905 | ||
| 906 | - [ ] **Step 8: Commit** | |
| 907 | ||
| 908 | ```bash | |
| 909 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRModels.swift gitbay/MRs/MRDetailViewModel.swift gitbay/Views/MRs/MRView.swift gitbayTests/MRLabelTests.swift && git commit -m "mr: labels on the detail screen | |
| 910 | ||
| 911 | mr label --add/--remove, with chips coloured from the repository's | |
| 912 | label set, as issues have." | |
| 913 | ``` | |
| 914 | ||
| 915 | --- | |
| 916 | ||
| 917 | ### Task 6: Filter merge requests by label | |
| 918 | ||
| 919 | **Files:** | |
| 920 | - Modify: `gitbay/MRs/MRFilter.swift:4-5` (doc comment), `:21-31` (field and flag) | |
| 921 | - Modify: `gitbay/MRs/MRListViewModel.swift` (add `labels`, `colors`, `loadLabels()`) | |
| 922 | - Modify: `gitbay/Views/MRs/MRListView.swift:57-69` (filter sheet) | |
| 923 | - Modify: `gitbayTests/MRLabelTests.swift` (add `MRLabelFilterTests`) | |
| 924 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (fixture and `testMRLabelFlow`) | |
| 925 | ||
| 926 | **Interfaces:** | |
| 927 | - Consumes: `LabelFilterSection` (Task 4), `until(_:)` from `gitbayTests/StubProtocol.swift`. | |
| 928 | - Produces: `MRFilter.label: String?`; `MRListViewModel.labels: [IssueLabel]`, `colors: LabelColors`, `loadLabels() async`. | |
| 929 | ||
| 930 | - [ ] **Step 1: Write the failing tests** | |
| 931 | ||
| 932 | Append to `gitbayTests/MRLabelTests.swift`: | |
| 933 | ||
| 934 | ```swift | |
| 935 | struct MRLabelFilterFlagTests { | |
| 936 | ||
| 937 | /// Same position as in IssueFilter: after --search, before --author. | |
| 938 | @Test func aLabelSendsTheLabelFlag() { | |
| 939 | var filter = MRFilter() | |
| 940 | filter.search = "gate" | |
| 941 | filter.label = "bug" | |
| 942 | filter.author = "cmc" | |
| 943 | #expect(filter.flags() == ["--state", "open", "--search", "gate", "--label", "bug", "--author", "cmc"]) | |
| 944 | #expect(filter.isNarrowed) | |
| 945 | } | |
| 946 | ||
| 947 | @Test func noLabelSendsNoFlag() { | |
| 948 | #expect(!MRFilter().flags().contains("--label")) | |
| 949 | } | |
| 950 | } | |
| 951 | ||
| 952 | @MainActor | |
| 953 | struct MRLabelFilterTests { | |
| 954 | ||
| 955 | @Test func pickingALabelReloadsWithIt() async throws { | |
| 956 | let (client, stub) = try makeClient() | |
| 957 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | |
| 958 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | |
| 959 | await model.load() | |
| 960 | ||
| 961 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | |
| 962 | model.filter.label = "bug" | |
| 963 | await until { | |
| 964 | guard stub.seen.count == 2 else { return false } | |
| 965 | if case .loading = model.state { return false } | |
| 966 | return true | |
| 967 | } | |
| 968 | ||
| 969 | let query = try #require(stub.seen.last?.url.query()) | |
| 970 | #expect(query.contains("argv=--label&argv=bug")) | |
| 971 | } | |
| 972 | ||
| 973 | @Test func thePickerOffersTheRepositorysLabels() async throws { | |
| 974 | let (client, stub) = try makeClient() | |
| 975 | stub.enqueue(.init(status: 200, json: """ | |
| 976 | {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2},\ | |
| 977 | {"name":"ios","issues":0}],"exit_code":0} | |
| 978 | """, match: "argv=label&argv=list")) | |
| 979 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | |
| 980 | ||
| 981 | await model.loadLabels() | |
| 982 | await model.loadLabels() | |
| 983 | ||
| 984 | #expect(model.labels.map(\.name) == ["bug", "ios"]) | |
| 985 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | |
| 986 | #expect(stub.seen.count == 1) | |
| 987 | } | |
| 988 | } | |
| 989 | ``` | |
| 990 | ||
| 991 | - [ ] **Step 2: Run the unit target to verify it fails** | |
| 992 | ||
| 993 | Expected: build FAILS with `value of type 'MRFilter' has no member 'label'` and `value of type 'MRListViewModel' has no member 'loadLabels'`. | |
| 994 | ||
| 995 | - [ ] **Step 3: Add the field and its flag** | |
| 996 | ||
| 997 | In `gitbay/MRs/MRFilter.swift`, replace the doc comment | |
| 998 | ||
| 999 | ```swift | |
| 1000 | /// What `mr list` is narrowed to. The command takes no `--label` and no | |
| 1001 | /// `--assignee`, so this offers neither. | |
| 1002 | ``` | |
| 1003 | ||
| 1004 | with | |
| 1005 | ||
| 1006 | ```swift | |
| 1007 | /// What `mr list` is narrowed to. The command takes no `--assignee`, so | |
| 1008 | /// this offers none. | |
| 1009 | ``` | |
| 1010 | ||
| 1011 | Replace | |
| 1012 | ||
| 1013 | ```swift | |
| 1014 | var state: State = .open | |
| 1015 | var search = "" | |
| 1016 | var author: String? | |
| 1017 | var milestone: String? | |
| 1018 | ||
| 1019 | func flags() -> [String] { | |
| 1020 | var flags = ["--state", state.rawValue] | |
| 1021 | flags += filterFlag("--search", search) | |
| 1022 | flags += filterFlag("--author", author) | |
| 1023 | flags += filterFlag("--milestone", milestone) | |
| 1024 | return flags | |
| 1025 | } | |
| 1026 | ``` | |
| 1027 | ||
| 1028 | with | |
| 1029 | ||
| 1030 | ```swift | |
| 1031 | var state: State = .open | |
| 1032 | var search = "" | |
| 1033 | var label: String? | |
| 1034 | var author: String? | |
| 1035 | var milestone: String? | |
| 1036 | ||
| 1037 | func flags() -> [String] { | |
| 1038 | var flags = ["--state", state.rawValue] | |
| 1039 | flags += filterFlag("--search", search) | |
| 1040 | flags += filterFlag("--label", label) | |
| 1041 | flags += filterFlag("--author", author) | |
| 1042 | flags += filterFlag("--milestone", milestone) | |
| 1043 | return flags | |
| 1044 | } | |
| 1045 | ``` | |
| 1046 | ||
| 1047 | - [ ] **Step 4: Load the labels for the picker** | |
| 1048 | ||
| 1049 | In `gitbay/MRs/MRListViewModel.swift`, after `loadMilestones()` add: | |
| 1050 | ||
| 1051 | ```swift | |
| 1052 | /// The repository's labels for the filter picker and its chips; | |
| 1053 | /// fetched on first use, like the milestones. | |
| 1054 | private(set) var labels: [IssueLabel] = [] | |
| 1055 | private(set) var colors = LabelColors() | |
| 1056 | private var labelsLoaded = false | |
| 1057 | ||
| 1058 | func loadLabels() async { | |
| 1059 | guard !labelsLoaded else { return } | |
| 1060 | labelsLoaded = true | |
| 1061 | labels = (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | |
| 1062 | colors = LabelColors(labels) | |
| 1063 | } | |
| 1064 | ``` | |
| 1065 | ||
| 1066 | A label pick is not one of the typed fields `filter`'s `didSet` debounces, so it reloads immediately; `didSet` needs no change. | |
| 1067 | ||
| 1068 | - [ ] **Step 5: Run the unit target to verify it passes** | |
| 1069 | ||
| 1070 | Expected: 4 more than after Task 5, 0 failed. | |
| 1071 | ||
| 1072 | - [ ] **Step 6: Put the picker in the sheet** | |
| 1073 | ||
| 1074 | In `gitbay/Views/MRs/MRListView.swift`, replace | |
| 1075 | ||
| 1076 | ```swift | |
| 1077 | ) {} | |
| 1078 | .task { await model.loadMilestones() } | |
| 1079 | ``` | |
| 1080 | ||
| 1081 | with | |
| 1082 | ||
| 1083 | ```swift | |
| 1084 | ) { | |
| 1085 | LabelFilterSection( | |
| 1086 | label: Bindable(model).filter.label, | |
| 1087 | labels: model.labels, | |
| 1088 | colors: model.colors | |
| 1089 | ) | |
| 1090 | } | |
| 1091 | .task { | |
| 1092 | await model.loadMilestones() | |
| 1093 | await model.loadLabels() | |
| 1094 | } | |
| 1095 | ``` | |
| 1096 | ||
| 1097 | - [ ] **Step 7: Add the live fixture and test** | |
| 1098 | ||
| 1099 | In `gitbayUITests/LiveSmokeUITests.swift`: | |
| 1100 | ||
| 1101 | After `static let successorTitle = "UI smoke: successor"` add: | |
| 1102 | ||
| 1103 | ```swift | |
| 1104 | static let labelledTitle = "UI smoke: labels" | |
| 1105 | static let smokeMRLabel = "ui-smoke-mr-label" | |
| 1106 | ``` | |
| 1107 | ||
| 1108 | In `setUpWithError()`, after the `testCloseInFavourFlow` block, add: | |
| 1109 | ||
| 1110 | ```swift | |
| 1111 | if name.contains("testMRLabelFlow") { | |
| 1112 | try requireFixtureRepo() | |
| 1113 | try requireFixtureBranch() | |
| 1114 | closeSmokeMergeRequests() | |
| 1115 | _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", | |
| 1116 | "--title", Self.labelledTitle]) | |
| 1117 | try XCTSkipUnless(openSmokeMergeRequests().map { $0.title }.contains(Self.labelledTitle), | |
| 1118 | "could not open the label fixture merge request on \(Self.fixtureRepo)") | |
| 1119 | } | |
| 1120 | ``` | |
| 1121 | ||
| 1122 | In `tearDownWithError()`, add: | |
| 1123 | ||
| 1124 | ```swift | |
| 1125 | if name.contains("testMRLabelFlow") { | |
| 1126 | closeSmokeMergeRequests() | |
| 1127 | _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel]) | |
| 1128 | } | |
| 1129 | ``` | |
| 1130 | ||
| 1131 | After `testCloseInFavourFlow()`, add: | |
| 1132 | ||
| 1133 | ```swift | |
| 1134 | /// A label added from the merge request screen shows as a chip, the | |
| 1135 | /// list filters to it, and removing it clears the chip. | |
| 1136 | func testMRLabelFlow() throws { | |
| 1137 | openRepo(Self.fixtureRepo) | |
| 1138 | app.staticTexts["Merge Requests"].firstMatch.tap() | |
| 1139 | let row = app.staticTexts[Self.labelledTitle].firstMatch | |
| 1140 | XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") | |
| 1141 | row.tap() | |
| 1142 | ||
| 1143 | let field = app.textFields["mr-label-field"].firstMatch | |
| 1144 | XCTAssertTrue(scrollTo(field), "no label field on the merge request") | |
| 1145 | field.tap() | |
| 1146 | field.typeText(Self.smokeMRLabel) | |
| 1147 | app.buttons["mr-label-add"].firstMatch.tap() | |
| 1148 | let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch | |
| 1149 | XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip") | |
| 1150 | ||
| 1151 | back() | |
| 1152 | app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap() | |
| 1153 | let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch | |
| 1154 | XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker") | |
| 1155 | menu.tap() | |
| 1156 | let choice = app.buttons[Self.smokeMRLabel].firstMatch | |
| 1157 | XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker") | |
| 1158 | choice.tap() | |
| 1159 | app.buttons["Done"].firstMatch.tap() | |
| 1160 | XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR") | |
| 1161 | ||
| 1162 | row.tap() | |
| 1163 | XCTAssertTrue(scrollTo(remove), "chip missing on reopen") | |
| 1164 | remove.tap() | |
| 1165 | XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") | |
| 1166 | } | |
| 1167 | ``` | |
| 1168 | ||
| 1169 | - [ ] **Step 8: Build the UI test target** | |
| 1170 | ||
| 1171 | ```bash | |
| 1172 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 1173 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 | |
| 1174 | ``` | |
| 1175 | ||
| 1176 | Expected: `** TEST BUILD SUCCEEDED **`. | |
| 1177 | ||
| 1178 | - [ ] **Step 9: Run the live test (needs a token from the user)** | |
| 1179 | ||
| 1180 | ```bash | |
| 1181 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | |
| 1182 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | |
| 1183 | -only-testing:gitbayUITests/LiveSmokeUITests/testMRLabelFlow \ | |
| 1184 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | |
| 1185 | ``` | |
| 1186 | ||
| 1187 | Expected: `passed`. If the run dies mid-flow, close any open "UI smoke:" MR on `ios-smoke/ui-smoke-edit` and run `label remove ios-smoke/ui-smoke-edit ui-smoke-mr-label` over the API before rerunning. | |
| 1188 | ||
| 1189 | - [ ] **Step 10: Commit, push, open the MR** | |
| 1190 | ||
| 1191 | ```bash | |
| 1192 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRFilter.swift gitbay/MRs/MRListViewModel.swift gitbay/Views/MRs/MRListView.swift gitbayTests/MRLabelTests.swift gitbayUITests/LiveSmokeUITests.swift && git commit -m "mr: filter the list by label | |
| 1193 | ||
| 1194 | mr list takes --label; MRFilter's comment said it did not." && \ | |
| 1195 | git push -u origin mr-labels && gitbay mr create --source mr-labels --target main \ | |
| 1196 | --title "Merge request labels and filter by label" --file - <<'EOF' | |
| 1197 | - MRDetail decodes labels. The detail screen adds and removes them | |
| 1198 | with mr label --add/--remove, coloured from the repository's set. | |
| 1199 | - MRFilter gains --label; the list's filter sheet offers the | |
| 1200 | repository's labels. | |
| 1201 | - The chip row and label picker the issue screens had are shared views | |
| 1202 | now, used by both. | |
| 1203 | ||
| 1204 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | |
| 1205 | EOF | |
| 1206 | ``` | |
| 1207 | ||
| 1208 | --- | |
| 1209 | ||
| 1210 | ### Task 7: Bump to 1.4.0 (13) | |
| 1211 | ||
| 1212 | Starts only after the user has merged both MRs from Tasks 3 and 6. | |
| 1213 | ||
| 1214 | **Files:** | |
| 1215 | - Modify: `gitbay.xcodeproj/project.pbxproj` (two `MARKETING_VERSION` and two `CURRENT_PROJECT_VERSION` lines) | |
| 1216 | ||
| 1217 | - [ ] **Step 1: Branch from the merged main** | |
| 1218 | ||
| 1219 | ```bash | |
| 1220 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.4.0 | |
| 1221 | ``` | |
| 1222 | ||
| 1223 | - [ ] **Step 2: Bump the four lines** | |
| 1224 | ||
| 1225 | ```bash | |
| 1226 | cd /Users/cmc/git/krz/gitbay-ios && sed -i '' -e 's/MARKETING_VERSION = 1.3.0;/MARKETING_VERSION = 1.4.0;/' \ | |
| 1227 | -e 's/CURRENT_PROJECT_VERSION = 12;/CURRENT_PROJECT_VERSION = 13;/' gitbay.xcodeproj/project.pbxproj && \ | |
| 1228 | git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj | |
| 1229 | ``` | |
| 1230 | ||
| 1231 | Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. | |
| 1232 | ||
| 1233 | - [ ] **Step 3: Verify on both runtimes and a Release device build** | |
| 1234 | ||
| 1235 | Run the unit target on iPhone 17 Pro (26.5), then on iOS 18.6: | |
| 1236 | ||
| 1237 | ```bash | |
| 1238 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | |
| 1239 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" | |
| 1240 | xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | |
| 1241 | | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | |
| 1242 | ``` | |
| 1243 | ||
| 1244 | Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. | |
| 1245 | ||
| 1246 | - [ ] **Step 4: Commit, push, open the MR** | |
| 1247 | ||
| 1248 | ```bash | |
| 1249 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.4.0 (13)" && \ | |
| 1250 | git push -u origin bump-1.4.0 && gitbay mr create --source bump-1.4.0 --target main --title "Bump to 1.4.0 (13)" --body "Build list subject and paging; MR labels and filter by label." | |
| 1251 | ``` | |
| 1252 | ||
| 1253 | Tagging `v1.4.0` on the merge commit, archiving and uploading are the user's. | |
| 1254 | ||
| 1255 | - [ ] **Step 5: Flip the Parity rows upstream** | |
| 1256 | ||
| 1257 | Once 1.4.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.4.0`, set the iOS column to `yes` for `labels` and `filter by label` under Merge requests, and for `build list paging (limit, cursor)` and `build row names its commit` under Repositories. Commit, push, and open the MR with the title "Parity: iOS 1.4.0 rows". | |