docs/superpowers/plans/2026-09-22-1.4.0-builds-and-mr-labels.md

v1.6.0
gitbay-ios/docs/superpowers/plans/2026-09-22-1.4.0-builds-and-mr-labels.md rendered · source · history · blame · raw

1257 lines · 48179 bytes

   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".