# 1.4.0 Build List and MR Labels Implementation Plan > **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. **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. **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. **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.4.0: build list and MR labels") ## Global Constraints - **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. - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. - **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. - **Swift 6, default `MainActor` isolation.** Wire models are `nonisolated` structs. - **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. - **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. - **A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`.** `next` is absent on the last page. `PagedListModel` appends `--limit ` and, for later pages, `--cursor ` to its `argv`. - **Server order, never a client sort.** A client sort reorders rows across page boundaries. - **`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. - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. - **Run the whole unit target, and read the count.** `-only-testing:` on one test can report success with zero tests run. ```bash cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" ``` "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. --- ### Task 1: A build row names its commit **Files:** - Modify: `gitbay/Builds/BuildModels.swift:4-22` (`Build`) - Modify: `gitbay/Views/Builds/BuildListView.swift:69-99` (`BuildRow`) - Create: `gitbayTests/BuildListPagingTests.swift` **Interfaces:** - Produces: `Build.subject: String?`, `Build.title: String` (`"# "`, or `"# "` when there is no subject). - [ ] **Step 1: Branch** ```bash cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c build-list ``` Run the unit target and record the count as `BASE`. - [ ] **Step 2: Write the failing tests** Create `gitbayTests/BuildListPagingTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func buildRow(_ number: Int, subject: String? = nil) -> String { let subjectField = subject.map { #","subject":"\#($0)""# } ?? "" return """ {"number":\(number),"job":"test","status":"success",\ "sha":"db7503f06f11c31a41f1c8e91187e1ff8f861c7f","ref":"main",\ "created_at":"2026-09-22T04:40:16.000Z"\(subjectField)} """ } struct BuildSubjectTests { private func decode(_ json: String) throws -> Build { try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8)) } @Test func aRowLeadsWithTheCommitSubject() throws { let build = try decode(buildRow(1479, subject: "CHANGELOG: v1.34.1")) #expect(build.subject == "CHANGELOG: v1.34.1") #expect(build.title == "#1479 CHANGELOG: v1.34.1") } /// A build whose commit is gone has no subject to report; the sha is /// all there is. @Test func aRowWithoutASubjectFallsBackToTheSha() throws { let build = try decode(buildRow(12)) #expect(build.subject == nil) #expect(build.title == "#12 db7503f06f") } @Test func anEmptySubjectAlsoFallsBack() throws { let build = try decode(buildRow(12, subject: "")) #expect(build.title == "#12 db7503f06f") } } ``` - [ ] **Step 3: Run the unit target to verify it fails** Expected: build FAILS with `value of type 'Build' has no member 'subject'` and `… no member 'title'`. - [ ] **Step 4: Decode `subject` and derive the title** In `gitbay/Builds/BuildModels.swift`, in `Build`, add after `let finishedAt: Date?`: ```swift /// The built commit's subject, which `build list` resolves since /// gitbay v1.33.0. Absent when the commit is gone. let subject: String? ``` Change `case number, job, status, sha, ref` to `case number, job, status, sha, ref, subject`. Add after `var shortSHA`: ```swift /// What a row leads with. Recognising a build by its sha meant /// opening the commit or already knowing it (krz/gitbay#241). var title: String { if let subject, !subject.isEmpty { return "#\(number) \(subject)" } return "#\(number) \(shortSHA)" } ``` - [ ] **Step 5: Lead the row with it** In `gitbay/Views/Builds/BuildListView.swift`, in `BuildRow`, replace ```swift Text("#\(build.number) \(build.job)") .font(.gbSans(.subheadline).weight(.medium)) HStack(spacing: 6) { Text(build.ref) Text(build.shortSHA) .font(.gbMono(.caption)) } ``` with ```swift Text(build.title) .font(.gbSans(.subheadline).weight(.medium)) .lineLimit(2) HStack(spacing: 6) { Text(build.job) Text(build.ref) Text(build.shortSHA) .font(.gbMono(.caption)) } ``` - [ ] **Step 6: Run the unit target to verify it passes** Expected: `Passed 0`. - [ ] **Step 7: Commit** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildModels.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift && git commit -m "builds: lead each row with the commit subject build list returns subject since gitbay v1.33.0 (krz/gitbay#241). The job, ref and sha move to the meta line." ``` --- ### Task 2: The build list pages **Files:** - Modify: `gitbay/Builds/BuildListViewModel.swift` (whole file) - Modify: `gitbay/Views/Builds/BuildListView.swift:19-23` (add `PageFooter`) - Modify: `gitbayTests/BuildListPagingTests.swift` (add `BuildListPagingTests`) - Modify: `gitbayTests/IssueBuildViewModelTests.swift:32-40` (`buildListJSON`), `:146-193` (`BuildListViewModelTests`) - Modify: `gitbayTests/RepoManagementTests.swift:367-371` (`BuildTriggerTests.buildListJSON`) **Interfaces:** - Consumes: `PagedListModel(client:argv:emptyMessage:pageSize:)`, its `reload()`, `loadMore()`, `hasMore`, `argv`, `emptyMessage`, `state`; `PageFooter(list:)`. - Produces: `BuildListViewModel.list: PagedListModel`; `state`, `filter`, `jobs`, `load()`, `loadJobs()`, `trigger(job:)` keep their signatures. - [ ] **Step 1: Write the failing tests** Append to `gitbayTests/BuildListPagingTests.swift`: ```swift private func page(_ rows: [String], next: String? = nil) -> String { let nextField = next.map { #","next":"\#($0)""# } ?? "" return #"{"protocol_version":1,"data":{"items":["# + rows.joined(separator: ",") + "]" + nextField + #"},"exit_code":0}"# } @MainActor struct BuildListPagingTests { /// The web asks for thirty; so does the app. @Test func asksForThirtyAndFollowsTheCursor() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(5), buildRow(4)], next: "YnVpbGQ6NA"), match: "argv=build&argv=list")) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(try #require(model.state.value).map(\.number) == [5, 4]) #expect(model.list.hasMore) let first = try #require(stub.seen.first) #expect(first.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--limit&argv=30") stub.enqueue(.init(status: 200, json: page([buildRow(3)]), match: "argv=build&argv=list")) await model.list.loadMore() #expect(try #require(model.state.value).map(\.number) == [5, 4, 3]) #expect(model.list.hasMore == false) let second = try #require(stub.seen.last) #expect(second.url.query()?.hasSuffix("argv=--limit&argv=30&argv=--cursor&argv=YnVpbGQ6NA") == true) } /// Rows are shown in the order the server sent them. A client sort /// would reorder rows across a page boundary. @Test func keepsTheServersOrder() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(3), buildRow(4)]))) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() #expect(try #require(model.state.value).map(\.number) == [3, 4]) } @Test func aFilterChangeStartsOverFromTheFirstPage() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: page([buildRow(5)], next: "YnVpbGQ6NQ"))) let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: page([]))) var filter = BuildFilter() filter.status = .failure model.filter = filter await until { if case .empty = model.state { return true } return false } let second = try #require(stub.seen.last) #expect(second.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--limit&argv=30") #expect(model.list.hasMore == false) } } ``` In `gitbayTests/IssueBuildViewModelTests.swift`, replace `buildListJSON` with the paged shape, keeping the rows in the order the server would send them (newest first): ```swift private let buildListJSON = """ {"protocol_version":1,"data":{"items":[\ {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"},\ {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ "finished_at":"2026-08-20T10:05:00.000Z"}\ ]},"exit_code":0} """ ``` In `BuildListViewModelTests`, rename `listsNewestFirst()` to `decodesEachRow()` (its assertions stay: `[4, 3]`, `running`, `finishedAt`). In `changingTheFilterReloadsWithItsFlags()`, change the empty stub to ```swift stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":{"items":[]},"exit_code":0} """)) ``` and the expected query to ```swift == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test&argv=--limit&argv=30") ``` In `gitbayTests/RepoManagementTests.swift`, in `BuildTriggerTests`, replace `buildListJSON` with: ```swift private let buildListJSON = """ {"protocol_version":1,"data":{"items":[\ {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}]},"exit_code":0} """ ``` - [ ] **Step 2: Run the unit target to verify it fails** Expected: build FAILS with `value of type 'BuildListViewModel' has no member 'list'`. - [ ] **Step 3: Move the view model onto `PagedListModel`** Replace the whole of `gitbay/Builds/BuildListViewModel.swift` with: ```swift import Foundation import Observation /// `build list `, narrowed by `filter`, thirty to a page as the web /// pages it. Rows stay in server order: a client sort would reorder them /// across a page boundary. @Observable @MainActor final class BuildListViewModel { let list: PagedListModel /// A change reloads from the first page. The sheet edits a copy and /// assigns once, so a typed branch does not fire a request per /// keystroke. var filter = BuildFilter() { didSet { guard filter != oldValue else { return } list.argv = ["build", "list", repoPath] + filter.flags() list.emptyMessage = filter.emptyMessage reloadTask?.cancel() reloadTask = Task { await list.reload() } } } private var reloadTask: Task? /// The jobs a trigger can name. Empty when the repo has no CI /// config — the web hides its trigger form in that case. private(set) var jobs: [CIJob] = [] private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath list = PagedListModel( client: client, argv: ["build", "list", repoPath] + BuildFilter().flags(), emptyMessage: BuildFilter().emptyMessage, pageSize: 30 ) } var state: LoadState<[Build]> { list.state } func load() async { reloadTask?.cancel() await list.reload() } /// `build jobs ` — what the picker offers. A repo /// without a CI config answers "not found"; that means no jobs, not /// a failure worth showing. func loadJobs() async { jobs = (try? await client.readList(["build", "jobs", repoPath], of: CIJob.self)) ?? [] } /// `build trigger ` — queue a job now. func trigger(job: String) async { working = true actionError = nil defer { working = false } do { try await client.run(["build", "trigger", repoPath, job]) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } } ``` - [ ] **Step 4: Page from the list's last row** In `gitbay/Views/Builds/BuildListView.swift`, after the `ForEach(model.state.value ?? []) { … }` block inside the `List`, add: ```swift PageFooter(list: model.list) ``` - [ ] **Step 5: Run the unit target to verify it passes** Expected: `Passed 0`. - [ ] **Step 6: Commit** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildListViewModel.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift gitbayTests/IssueBuildViewModelTests.swift gitbayTests/RepoManagementTests.swift && git commit -m "builds: page the list thirty at a time build list takes --limit and --cursor since gitbay v1.33.0 (krz/gitbay#244). The list moves onto PagedListModel and keeps server order." ``` --- ### Task 3: Live steps for the build list, then the MR **Files:** - Modify: `gitbayUITests/LiveSmokeUITests.swift:1760-1766` (`testBuildFilterFlow`, the job step) and a new `testBuildPagingFlow()` after `testBuildFilterFlow()` **Interfaces:** - Consumes: `readCommand(_:)`, `openRepo(_:)`, `scrollTo(_:swipes:)` in the live suite. - [ ] **Step 1: The job now has its own text** In `testBuildFilterFlow()`, replace ```swift XCTAssertTrue(app.staticTexts .containing(NSPredicate(format: "label CONTAINS ' test'")).firstMatch .waitForExistence(timeout: 20), "job filter did not narrow to test") ``` with ```swift XCTAssertTrue(app.staticTexts["test"].firstMatch .waitForExistence(timeout: 20), "job filter did not narrow to test") ``` - [ ] **Step 2: Add the paging test** After `testBuildFilterFlow()`, add: ```swift /// krz/gitbay has well over thirty builds, so the thirty-first is on /// the second page. Reaching it means the footer asked for more. func testBuildPagingFlow() throws { guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let page = envelope["data"] as? [String: Any], let items = page["items"] as? [[String: Any]], items.count == 31, let number = items[30]["number"] as? Int else { throw XCTSkip("could not read krz/gitbay's builds over the API") } openRepo("krz/gitbay") app.staticTexts["Builds"].firstMatch.tap() XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") let thirtyFirst = app.staticTexts .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20), "build #\(number), the first row of page two, never appeared") } ``` - [ ] **Step 3: Build the UI test target** ```bash cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 ``` Expected: `** TEST BUILD SUCCEEDED **`. - [ ] **Step 4: Run the two live tests (needs a token from the user)** Ask the user for a token for `ios-smoke` (`ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d`). `testBuildFilterFlow` fails when krz/gitbay has a build pending or running; wait until `gitbay build list krz/gitbay --status running --json` and `--status pending` both return no rows. ```bash cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ -only-testing:gitbayUITests/LiveSmokeUITests/testBuildFilterFlow \ -only-testing:gitbayUITests/LiveSmokeUITests/testBuildPagingFlow \ -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 ``` Expected: both `passed`. Read failures from the bundle, not stdout. If no token is available, record that the live steps were not run. - [ ] **Step 5: Commit, push, open the MR** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: build rows by subject, and the second page" && \ git push -u origin build-list && gitbay mr create --source build-list --target main \ --title "Build list: name the commit, page past thirty" --file - <<'EOF' - Build decodes subject (gitbay v1.33.0, krz/gitbay#241). A row leads with "# ", falling back to the sha when the commit is gone; job, ref and sha move to the meta line. - The list moves onto PagedListModel at thirty a page (krz/gitbay#244) and keeps server order. Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md EOF ``` --- ### Task 4: Shared chip row and label picker A refactor with no behaviour change, so the merge request screens in Tasks 5 and 6 use the issue screens' views rather than a copy. **Files:** - Create: `gitbay/Views/Shared/RemovableChips.swift` - Modify: `gitbay/Views/Issues/IssueView.swift:93-96`, `:112-114` (call sites), `:155-181` (delete `labelFlow`) - Modify: `gitbay/Views/Shared/FilterSheet.swift:1-6` (doc comment), `:60-98` (`IssueFilterExtra` → `LabelFilterSection` + assignee) **Interfaces:** - Produces: `RemovableChips(items: [String], idPrefix: String, disabled: Bool, color: (String) -> Color = { _ in .secondary }, remove: (String) -> Void)`; `LabelFilterSection(label: Binding, labels: [IssueLabel], colors: LabelColors)`. `IssueFilterExtra`'s initializer is unchanged. - [ ] **Step 1: Branch** ```bash cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c mr-labels ``` - [ ] **Step 2: Create the chip row** Create `gitbay/Views/Shared/RemovableChips.swift`: ```swift import SwiftUI /// A horizontal row of chips, each with a remove button: an issue's /// labels and assignees, a merge request's labels. The remove button is /// identified as `-remove-`. struct RemovableChips: View { let items: [String] let idPrefix: String let disabled: Bool var color: (String) -> Color = { _ in .secondary } let remove: (String) -> Void var body: some View { if !items.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(items, id: \.self) { item in HStack(spacing: 3) { Text(item) Button { remove(item) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .disabled(disabled) .accessibilityIdentifier("\(idPrefix)-remove-\(item)") } .font(.gbSans(.caption)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(color(item).opacity(0.07), in: gbChipShape) .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1)) } } } } } } ``` - [ ] **Step 3: Use it in the issue screen** In `gitbay/Views/Issues/IssueView.swift`, replace ```swift labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in Task { await model.removeLabel(label) } }) ``` with ```swift RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label", disabled: model.working, color: { model.colors.color($0) }, remove: { label in Task { await model.removeLabel(label) } }) ``` replace ```swift labelFlow(issue.assignees ?? [], remove: { user in Task { await model.removeAssignee(user) } }) ``` with ```swift RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee", disabled: model.working, remove: { user in Task { await model.removeAssignee(user) } }) ``` and delete the whole `private func labelFlow(…)` function with its `@ViewBuilder` attribute. - [ ] **Step 4: Split the label picker out of `IssueFilterExtra`** In `gitbay/Views/Shared/FilterSheet.swift`, replace the doc comment on `FilterSheet` with: ```swift /// The issue and MR lists' filter sheet. A `Form` with the fields both /// lists share — author and milestone — plus an `extra` slot for the /// rows each list adds: the label picker on both, and an assignee field /// on issues only, since `mr list` takes no `--assignee`. ``` Replace the whole of `IssueFilterExtra` (the struct and its doc comment) with: ```swift /// The label picker both lists' sheets add through `FilterSheet`'s /// `extra` slot. struct LabelFilterSection: View { @Binding var label: String? let labels: [IssueLabel] let colors: LabelColors var body: some View { Section("Label") { Menu { Button("Any label") { label = nil } ForEach(labels) { entry in Button { label = entry.name } label: { Text(entry.name) } } } label: { HStack { if let label { GBChip(label, colors.color(label)) } else { Text("Any label") } Spacer() Image(systemName: "chevron.up.chevron.down") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } .accessibilityIdentifier("filter-label-menu") } } } /// The label and assignee rows the issue list's sheet adds through /// `FilterSheet`'s `extra` slot. struct IssueFilterExtra: View { @Binding var label: String? @Binding var assignee: String? let labels: [IssueLabel] let colors: LabelColors var body: some View { LabelFilterSection(label: $label, labels: labels, colors: colors) Section("Assignee") { TextField("Username", text: $assignee.orEmpty) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("filter-assignee") } } } ``` - [ ] **Step 5: Run the unit target** Expected: same count as before this task, 0 failed. Nothing here has a unit test; the issue screens' behaviour is unchanged. - [ ] **Step 6: Commit** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/RemovableChips.swift gitbay/Views/Issues/IssueView.swift gitbay/Views/Shared/FilterSheet.swift && git commit -m "views: share the chip row and the label picker Merge requests are about to carry labels too; both screens use one copy." ``` --- ### Task 5: Label a merge request **Files:** - Modify: `gitbay/MRs/MRModels.swift:75-157` (`MRDetail`: add `labels`) - Modify: `gitbay/MRs/MRDetailViewModel.swift` (add `colors`, `loadLabels()`, `addLabel(_:)`, `removeLabel(_:)`) - Modify: `gitbay/Views/MRs/MRView.swift` (state `editingLabel`, `labelsSection`, call it after `milestoneSection(mr)`) - Create: `gitbayTests/MRLabelTests.swift` **Interfaces:** - Consumes: `RemovableChips` (Task 4), `IssueLabel`, `LabelColors`. - Produces: `MRDetail.labels: [String]`; `MRDetailViewModel.colors: LabelColors`, `loadLabels() async`, `addLabel(_ label: String) async`, `removeLabel(_ label: String) async`. - [ ] **Step 1: Write the failing tests** Create `gitbayTests/MRLabelTests.swift`: ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func mrShow(labels: String?) -> String { let field = labels.map { #","labels":\#($0)"# } ?? "" return """ {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ "source":"feature","target_ref":"main","head_sha":"aa",\ "created_at":"2026-09-22T04:40:16.000Z"\(field)},"exit_code":0} """ } private let okJSON = #"{"protocol_version":1,"exit_code":0}"# private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } @MainActor struct MRLabelTests { @Test func mrShowCarriesItsLabels() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug","ios"]"#), match: "argv=show")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() #expect(try #require(model.state.value).labels == ["bug", "ios"]) } /// The server omits the key when there are none. @Test func noLabelsKeyMeansNoLabels() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.load() #expect(try #require(model.state.value).labels.isEmpty) } @Test func addingAndRemovingSendMrLabel() async throws { let (client, stub) = try makeClient() let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug"]"#), match: "argv=show")) await model.addLabel("bug") stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) await model.removeLabel("bug") let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) #expect(writes == [ ["mr", "label", "krz/gitbay", "7", "--add", "bug"], ["mr", "label", "krz/gitbay", "7", "--remove", "bug"], ]) #expect(model.actionError == nil) } /// Colours come from the repository's label set, read once. @Test func labelColoursAreReadOnce() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2}],"exit_code":0} """, match: "argv=label&argv=list")) let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) await model.loadLabels() await model.loadLabels() #expect(stub.seen.count == 1) #expect(stub.seen[0].url.query() == "argv=label&argv=list&argv=krz/gitbay") #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) } } ``` - [ ] **Step 2: Run the unit target to verify it fails** Expected: build FAILS with `value of type 'MRDetail' has no member 'labels'` and `value of type 'MRDetailViewModel' has no member 'addLabel'` (and `removeLabel`, `loadLabels`, `colors`). - [ ] **Step 3: Decode `labels` on `MRDetail`** In `gitbay/MRs/MRModels.swift`, in `MRDetail`: After `let milestone: String?` add: ```swift /// Omitted by the server when there are none. let labels: [String] ``` In `CodingKeys`, change `case number, title, state, author, source, body, milestone, checks, …` to include `labels` after `milestone`: ```swift case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates ``` In `init(from:)`, after the `milestone = …` line add: ```swift labels = try container.decodeIfPresent([String].self, forKey: .labels) ?? [] ``` - [ ] **Step 4: Add the reads and writes to the view model** In `gitbay/MRs/MRDetailViewModel.swift`, after `setMilestone(_:)` add: ```swift /// The repository's labels, for chip colours. Read on first use from /// the Labels section rather than in `load()`, which stays at four /// reads. private(set) var colors = LabelColors() private var labelsLoaded = false func loadLabels() async { guard !labelsLoaded else { return } labelsLoaded = true colors = LabelColors( (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []) } /// A label that does not exist yet is created on the repository. func addLabel(_ label: String) async { await perform(["mr", "label"] + ref + ["--add", label]) } func removeLabel(_ label: String) async { await perform(["mr", "label"] + ref + ["--remove", label]) } ``` - [ ] **Step 5: Run the unit target to verify it passes** Expected: 4 more than before this task, 0 failed. `MRViewModelTests` and `MRLifecycleTests` still pass, since `load()` is unchanged. - [ ] **Step 6: Add the Labels section** In `gitbay/Views/MRs/MRView.swift`, add to the `@State` properties at the top: ```swift @State private var editingLabel = "" ``` Replace ` milestoneSection(mr)` in `body` with: ```swift milestoneSection(mr) labelsSection(mr) ``` Add after the `milestoneSection(_:)` function: ```swift private func labelsSection(_ mr: MRDetail) -> some View { Section("Labels") { RemovableChips(items: mr.labels, idPrefix: "mr-label", disabled: model.working, color: { model.colors.color($0) }, remove: { label in Task { await model.removeLabel(label) } }) HStack { TextField("Add label", text: $editingLabel) .autocorrectionDisabled() .textInputAutocapitalization(.never) .accessibilityIdentifier("mr-label-field") Button { let label = editingLabel.trimmingCharacters(in: .whitespaces) editingLabel = "" Task { await model.addLabel(label) } } label: { Image(systemName: "plus.circle.fill") } .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) .accessibilityIdentifier("mr-label-add") } } .task { await model.loadLabels() } } ``` - [ ] **Step 7: Run the unit target** Expected: same count as Step 5, 0 failed. - [ ] **Step 8: Commit** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRModels.swift gitbay/MRs/MRDetailViewModel.swift gitbay/Views/MRs/MRView.swift gitbayTests/MRLabelTests.swift && git commit -m "mr: labels on the detail screen mr label --add/--remove, with chips coloured from the repository's label set, as issues have." ``` --- ### Task 6: Filter merge requests by label **Files:** - Modify: `gitbay/MRs/MRFilter.swift:4-5` (doc comment), `:21-31` (field and flag) - Modify: `gitbay/MRs/MRListViewModel.swift` (add `labels`, `colors`, `loadLabels()`) - Modify: `gitbay/Views/MRs/MRListView.swift:57-69` (filter sheet) - Modify: `gitbayTests/MRLabelTests.swift` (add `MRLabelFilterTests`) - Modify: `gitbayUITests/LiveSmokeUITests.swift` (fixture and `testMRLabelFlow`) **Interfaces:** - Consumes: `LabelFilterSection` (Task 4), `until(_:)` from `gitbayTests/StubProtocol.swift`. - Produces: `MRFilter.label: String?`; `MRListViewModel.labels: [IssueLabel]`, `colors: LabelColors`, `loadLabels() async`. - [ ] **Step 1: Write the failing tests** Append to `gitbayTests/MRLabelTests.swift`: ```swift struct MRLabelFilterFlagTests { /// Same position as in IssueFilter: after --search, before --author. @Test func aLabelSendsTheLabelFlag() { var filter = MRFilter() filter.search = "gate" filter.label = "bug" filter.author = "cmc" #expect(filter.flags() == ["--state", "open", "--search", "gate", "--label", "bug", "--author", "cmc"]) #expect(filter.isNarrowed) } @Test func noLabelSendsNoFlag() { #expect(!MRFilter().flags().contains("--label")) } } @MainActor struct MRLabelFilterTests { @Test func pickingALabelReloadsWithIt() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.load() stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) model.filter.label = "bug" await until { guard stub.seen.count == 2 else { return false } if case .loading = model.state { return false } return true } let query = try #require(stub.seen.last?.url.query()) #expect(query.contains("argv=--label&argv=bug")) } @Test func thePickerOffersTheRepositorysLabels() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2},\ {"name":"ios","issues":0}],"exit_code":0} """, match: "argv=label&argv=list")) let model = MRListViewModel(client: client, repoPath: "krz/gitbay") await model.loadLabels() await model.loadLabels() #expect(model.labels.map(\.name) == ["bug", "ios"]) #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) #expect(stub.seen.count == 1) } } ``` - [ ] **Step 2: Run the unit target to verify it fails** Expected: build FAILS with `value of type 'MRFilter' has no member 'label'` and `value of type 'MRListViewModel' has no member 'loadLabels'`. - [ ] **Step 3: Add the field and its flag** In `gitbay/MRs/MRFilter.swift`, replace the doc comment ```swift /// What `mr list` is narrowed to. The command takes no `--label` and no /// `--assignee`, so this offers neither. ``` with ```swift /// What `mr list` is narrowed to. The command takes no `--assignee`, so /// this offers none. ``` Replace ```swift var state: State = .open var search = "" var author: String? var milestone: String? func flags() -> [String] { var flags = ["--state", state.rawValue] flags += filterFlag("--search", search) flags += filterFlag("--author", author) flags += filterFlag("--milestone", milestone) return flags } ``` with ```swift var state: State = .open var search = "" var label: String? var author: String? var milestone: String? func flags() -> [String] { var flags = ["--state", state.rawValue] flags += filterFlag("--search", search) flags += filterFlag("--label", label) flags += filterFlag("--author", author) flags += filterFlag("--milestone", milestone) return flags } ``` - [ ] **Step 4: Load the labels for the picker** In `gitbay/MRs/MRListViewModel.swift`, after `loadMilestones()` add: ```swift /// The repository's labels for the filter picker and its chips; /// fetched on first use, like the milestones. private(set) var labels: [IssueLabel] = [] private(set) var colors = LabelColors() private var labelsLoaded = false func loadLabels() async { guard !labelsLoaded else { return } labelsLoaded = true labels = (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] colors = LabelColors(labels) } ``` A label pick is not one of the typed fields `filter`'s `didSet` debounces, so it reloads immediately; `didSet` needs no change. - [ ] **Step 5: Run the unit target to verify it passes** Expected: 4 more than after Task 5, 0 failed. - [ ] **Step 6: Put the picker in the sheet** In `gitbay/Views/MRs/MRListView.swift`, replace ```swift ) {} .task { await model.loadMilestones() } ``` with ```swift ) { LabelFilterSection( label: Bindable(model).filter.label, labels: model.labels, colors: model.colors ) } .task { await model.loadMilestones() await model.loadLabels() } ``` - [ ] **Step 7: Add the live fixture and test** In `gitbayUITests/LiveSmokeUITests.swift`: After `static let successorTitle = "UI smoke: successor"` add: ```swift static let labelledTitle = "UI smoke: labels" static let smokeMRLabel = "ui-smoke-mr-label" ``` In `setUpWithError()`, after the `testCloseInFavourFlow` block, add: ```swift if name.contains("testMRLabelFlow") { try requireFixtureRepo() try requireFixtureBranch() closeSmokeMergeRequests() _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", "--title", Self.labelledTitle]) try XCTSkipUnless(openSmokeMergeRequests().map { $0.title }.contains(Self.labelledTitle), "could not open the label fixture merge request on \(Self.fixtureRepo)") } ``` In `tearDownWithError()`, add: ```swift if name.contains("testMRLabelFlow") { closeSmokeMergeRequests() _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel]) } ``` After `testCloseInFavourFlow()`, add: ```swift /// A label added from the merge request screen shows as a chip, the /// list filters to it, and removing it clears the chip. func testMRLabelFlow() throws { openRepo(Self.fixtureRepo) app.staticTexts["Merge Requests"].firstMatch.tap() let row = app.staticTexts[Self.labelledTitle].firstMatch XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") row.tap() let field = app.textFields["mr-label-field"].firstMatch XCTAssertTrue(scrollTo(field), "no label field on the merge request") field.tap() field.typeText(Self.smokeMRLabel) app.buttons["mr-label-add"].firstMatch.tap() let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip") back() app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap() let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker") menu.tap() let choice = app.buttons[Self.smokeMRLabel].firstMatch XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker") choice.tap() app.buttons["Done"].firstMatch.tap() XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR") row.tap() XCTAssertTrue(scrollTo(remove), "chip missing on reopen") remove.tap() XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") } ``` - [ ] **Step 8: Build the UI test target** ```bash cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 ``` Expected: `** TEST BUILD SUCCEEDED **`. - [ ] **Step 9: Run the live test (needs a token from the user)** ```bash cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ -only-testing:gitbayUITests/LiveSmokeUITests/testMRLabelFlow \ -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 ``` Expected: `passed`. If the run dies mid-flow, close any open "UI smoke:" MR on `ios-smoke/ui-smoke-edit` and run `label remove ios-smoke/ui-smoke-edit ui-smoke-mr-label` over the API before rerunning. - [ ] **Step 10: Commit, push, open the MR** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRFilter.swift gitbay/MRs/MRListViewModel.swift gitbay/Views/MRs/MRListView.swift gitbayTests/MRLabelTests.swift gitbayUITests/LiveSmokeUITests.swift && git commit -m "mr: filter the list by label mr list takes --label; MRFilter's comment said it did not." && \ git push -u origin mr-labels && gitbay mr create --source mr-labels --target main \ --title "Merge request labels and filter by label" --file - <<'EOF' - MRDetail decodes labels. The detail screen adds and removes them with mr label --add/--remove, coloured from the repository's set. - MRFilter gains --label; the list's filter sheet offers the repository's labels. - The chip row and label picker the issue screens had are shared views now, used by both. Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md EOF ``` --- ### Task 7: Bump to 1.4.0 (13) Starts only after the user has merged both MRs from Tasks 3 and 6. **Files:** - Modify: `gitbay.xcodeproj/project.pbxproj` (two `MARKETING_VERSION` and two `CURRENT_PROJECT_VERSION` lines) - [ ] **Step 1: Branch from the merged main** ```bash cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.4.0 ``` - [ ] **Step 2: Bump the four lines** ```bash cd /Users/cmc/git/krz/gitbay-ios && sed -i '' -e 's/MARKETING_VERSION = 1.3.0;/MARKETING_VERSION = 1.4.0;/' \ -e 's/CURRENT_PROJECT_VERSION = 12;/CURRENT_PROJECT_VERSION = 13;/' gitbay.xcodeproj/project.pbxproj && \ git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj ``` Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. - [ ] **Step 3: Verify on both runtimes and a Release device build** Run the unit target on iPhone 17 Pro (26.5), then on iOS 18.6: ```bash cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 ``` Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. - [ ] **Step 4: Commit, push, open the MR** ```bash cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.4.0 (13)" && \ git push -u origin bump-1.4.0 && gitbay mr create --source bump-1.4.0 --target main --title "Bump to 1.4.0 (13)" --body "Build list subject and paging; MR labels and filter by label." ``` Tagging `v1.4.0` on the merge commit, archiving and uploading are the user's. - [ ] **Step 5: Flip the Parity rows upstream** Once 1.4.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.4.0`, set the iOS column to `yes` for `labels` and `filter by label` under Merge requests, and for `build list paging (limit, cursor)` and `build row names its commit` under Repositories. Commit, push, and open the MR with the title "Parity: iOS 1.4.0 rows".