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

v1.8.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.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 <n> and, for later pages, --cursor <c> 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.

    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 ("#<number> <subject>", or "#<number> <shortSHA>" when there is no subject).

  • Step 1: Branch

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:

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?:

    /// 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:

    /// 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

                Text("#\(build.number) \(build.job)")
                    .font(.gbSans(.subheadline).weight(.medium))
                HStack(spacing: 6) {
                    Text(build.ref)
                    Text(build.shortSHA)
                        .font(.gbMono(.caption))
                }

with

                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 <BASE + 3> 0.

  • Step 7: Commit
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<Element>(client:argv:emptyMessage:pageSize:), its reload(), loadMore(), hasMore, argv, emptyMessage, state; PageFooter(list:).

  • Produces: BuildListViewModel.list: PagedListModel<Build>; state, filter, jobs, load(), loadJobs(), trigger(job:) keep their signatures.

  • Step 1: Write the failing tests

Append to gitbayTests/BuildListPagingTests.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):

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

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"items":[]},"exit_code":0}
            """))

and the expected query to

            == "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:

    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:

import Foundation
import Observation

/// `build list <repo>`, 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<Build>
    /// 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<Void, Never>?
    /// 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 <owner/name>` — 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 <owner/name> <job>` — 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:

            PageFooter(list: model.list)
  • Step 5: Run the unit target to verify it passes

Expected: Passed <BASE + 6> 0.

  • Step 6: Commit
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

        XCTAssertTrue(app.staticTexts
            .containing(NSPredicate(format: "label CONTAINS ' test'")).firstMatch
            .waitForExistence(timeout: 20), "job filter did not narrow to test")

with

        XCTAssertTrue(app.staticTexts["test"].firstMatch
            .waitForExistence(timeout: 20), "job filter did not narrow to test")
  • Step 2: Add the paging test

After testBuildFilterFlow(), add:

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

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
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 "#<n> <subject>", 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<String?>, labels: [IssueLabel], colors: LabelColors). IssueFilterExtra's initializer is unchanged.

  • Step 1: Branch

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:

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 `<idPrefix>-remove-<item>`.
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

            labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in
                Task { await model.removeLabel(label) }
            })

with

            RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label",
                           disabled: model.working, color: { model.colors.color($0) },
                           remove: { label in Task { await model.removeLabel(label) } })

replace

            labelFlow(issue.assignees ?? [], remove: { user in
                Task { await model.removeAssignee(user) }
            })

with

            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:

/// 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:

/// 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
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:

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:

    /// 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:

        case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates

In init(from:), after the milestone = … line add:

        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:

    /// 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:

    @State private var editingLabel = ""

Replace milestoneSection(mr) in body with:

                milestoneSection(mr)
                labelsSection(mr)

Add after the milestoneSection(_:) function:

    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
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:

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

/// What `mr list` is narrowed to. The command takes no `--label` and no
/// `--assignee`, so this offers neither.

with

/// What `mr list` is narrowed to. The command takes no `--assignee`, so
/// this offers none.

Replace

    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

    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:

    /// 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

            ) {}
            .task { await model.loadMilestones() }

with

            ) {
                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:

    static let labelledTitle = "UI smoke: labels"
    static let smokeMRLabel = "ui-smoke-mr-label"

In setUpWithError(), after the testCloseInFavourFlow block, add:

        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:

        if name.contains("testMRLabelFlow") {
            closeSmokeMergeRequests()
            _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel])
        }

After testCloseInFavourFlow(), add:

    /// 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
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)
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
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

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
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:

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