Commit a6f707b7db

a6f707b7db3c83c981ec38fe88bb656f2f7dbf37

parent: cfbeb7f5a5

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 17:38 UTC

docs: plan for 1.4.0, build list and MR labels

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
52cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c build-list
53```
54
55Run the unit target and record the count as `BASE`.
56
57- [ ] **Step 2: Write the failing tests**
58
59Create `gitbayTests/BuildListPagingTests.swift`:
60
61```swift
62import Foundation
63import Testing
64@testable import gitbay
65
66private 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
76private 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
85struct 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
114Expected: 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
118In `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
126Change `case number, job, status, sha, ref` to `case number, job, status, sha, ref, subject`.
127
128Add 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
141In `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
153with
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
169Expected: `Passed <BASE + 3> 0`.
170
171- [ ] **Step 7: Commit**
172
173```bash
174cd /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
176build list returns subject since gitbay v1.33.0 (krz/gitbay#241). The
177job, 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
197Append to `gitbayTests/BuildListPagingTests.swift`:
198
199```swift
200private 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
207struct 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
267In `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
270private 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
281In `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
289and 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
295In `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
307Expected: build FAILS with `value of type 'BuildListViewModel' has no member 'list'`.
308
309- [ ] **Step 3: Move the view model onto `PagedListModel`**
310
311Replace the whole of `gitbay/Builds/BuildListViewModel.swift` with:
312
313```swift
314import Foundation
315import 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
322final 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
391In `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
399Expected: `Passed <BASE + 6> 0`.
400
401- [ ] **Step 6: Commit**
402
403```bash
404cd /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
406build list takes --limit and --cursor since gitbay v1.33.0
407(krz/gitbay#244). The list moves onto PagedListModel and keeps server
408order."
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
423In `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
431with
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
440After `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
466cd /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
470Expected: `** TEST BUILD SUCCEEDED **`.
471
472- [ ] **Step 4: Run the two live tests (needs a token from the user)**
473
474Ask 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
477cd /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
484Expected: 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
489cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: build rows by subject, and the second page" && \
490git 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
498Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
499EOF
500```
501
502---
503
504### Task 4: Shared chip row and label picker
505
506A 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
519cd /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
524Create `gitbay/Views/Shared/RemovableChips.swift`:
525
526```swift
527import 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>`.
532struct 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
571In `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
579with
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
587replace
588
589```swift
590 labelFlow(issue.assignees ?? [], remove: { user in
591 Task { await model.removeAssignee(user) }
592 })
593```
594
595with
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
603and delete the whole `private func labelFlow(…)` function with its `@ViewBuilder` attribute.
604
605- [ ] **Step 4: Split the label picker out of `IssueFilterExtra`**
606
607In `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
616Replace 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.
621struct 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.
658struct 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
679Expected: 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
684cd /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
686Merge requests are about to carry labels too; both screens use one
687copy."
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
706Create `gitbayTests/MRLabelTests.swift`:
707
708```swift
709import Foundation
710import Testing
711@testable import gitbay
712
713private 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
723private 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
732private let okJSON = #"{"protocol_version":1,"exit_code":0}"#
733
734private 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
740struct 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
802Expected: 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
806In `gitbay/MRs/MRModels.swift`, in `MRDetail`:
807
808After `let milestone: String?` add:
809
810```swift
811 /// Omitted by the server when there are none.
812 let labels: [String]
813```
814
815In `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
821In `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
829In `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
857Expected: 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
861In `gitbay/Views/MRs/MRView.swift`, add to the `@State` properties at the top:
862
863```swift
864 @State private var editingLabel = ""
865```
866
867Replace ` milestoneSection(mr)` in `body` with:
868
869```swift
870 milestoneSection(mr)
871 labelsSection(mr)
872```
873
874Add 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
904Expected: same count as Step 5, 0 failed.
905
906- [ ] **Step 8: Commit**
907
908```bash
909cd /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
911mr label --add/--remove, with chips coloured from the repository's
912label 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
932Append to `gitbayTests/MRLabelTests.swift`:
933
934```swift
935struct 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
953struct 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
993Expected: 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
997In `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
1004with
1005
1006```swift
1007/// What `mr list` is narrowed to. The command takes no `--assignee`, so
1008/// this offers none.
1009```
1010
1011Replace
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
1028with
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
1049In `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
1066A 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
1070Expected: 4 more than after Task 5, 0 failed.
1071
1072- [ ] **Step 6: Put the picker in the sheet**
1073
1074In `gitbay/Views/MRs/MRListView.swift`, replace
1075
1076```swift
1077 ) {}
1078 .task { await model.loadMilestones() }
1079```
1080
1081with
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
1099In `gitbayUITests/LiveSmokeUITests.swift`:
1100
1101After `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
1108In `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
1122In `tearDownWithError()`, add:
1123
1124```swift
1125 if name.contains("testMRLabelFlow") {
1126 closeSmokeMergeRequests()
1127 _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel])
1128 }
1129```
1130
1131After `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
1172cd /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
1176Expected: `** TEST BUILD SUCCEEDED **`.
1177
1178- [ ] **Step 9: Run the live test (needs a token from the user)**
1179
1180```bash
1181cd /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
1187Expected: `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
1192cd /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
1194mr list takes --label; MRFilter's comment said it did not." && \
1195git 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
1204Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
1205EOF
1206```
1207
1208---
1209
1210### Task 7: Bump to 1.4.0 (13)
1211
1212Starts 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
1220cd /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
1226cd /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 && \
1228git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj
1229```
1230
1231Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`.
1232
1233- [ ] **Step 3: Verify on both runtimes and a Release device build**
1234
1235Run the unit target on iPhone 17 Pro (26.5), then on iOS 18.6:
1236
1237```bash
1238cd /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)"
1240xcodebuild 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
1244Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`.
1245
1246- [ ] **Step 4: Commit, push, open the MR**
1247
1248```bash
1249cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.4.0 (13)" && \
1250git 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
1253Tagging `v1.4.0` on the merge commit, archiving and uploading are the user's.
1254
1255- [ ] **Step 5: Flip the Parity rows upstream**
1256
1257Once 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".