docs/superpowers/plans/2026-09-22-1.4.0-builds-and-mr-labels.md
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-Bytrailer. This is absolute. -
Commits are signed.
commit.gpgsignis on; do not pass--no-gpg-sign. -
Never push to
main. Never merge. Two branches, one MR each:build-list(Tasks 1–3) andmr-labels(Tasks 4–6), both cut frommainafter the 1.3.0 work has merged. Task 7 is the version bump and waits for the user to merge both. -
Swift 6, default
MainActorisolation. Wire models arenonisolatedstructs. -
File-system-synchronized groups. New
.swiftfiles undergitbay/andgitbayTests/are picked up automatically. Do not editproject.pbxprojexcept for the version lines in Task 7. -
Reads are
GET /api/v1/read?argv=…; writes arePOST /api/v1/cmd. A stub'smatch:is a URL substring. Assert writes by filteringstub.seenonmethod == "POST", never by position. -
A paged read's envelope is
{"data":{"items":[…],"next":"…"}}.nextis absent on the last page.PagedListModelappends--limit <n>and, for later pages,--cursor <c>to itsargv. -
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).MRViewModelTestsandMRLifecycleTestscount 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
subjectand 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(addPageFooter) - Modify:
gitbayTests/BuildListPagingTests.swift(addBuildListPagingTests) - 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:), itsreload(),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 newtestBuildPagingFlow()aftertestBuildFilterFlow()
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(deletelabelFlow) - 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: addlabels) - Modify:
gitbay/MRs/MRDetailViewModel.swift(addcolors,loadLabels(),addLabel(_:),removeLabel(_:)) - Modify:
gitbay/Views/MRs/MRView.swift(stateeditingLabel,labelsSection, call it aftermilestoneSection(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
labelsonMRDetail
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(addlabels,colors,loadLabels()) - Modify:
gitbay/Views/MRs/MRListView.swift:57-69(filter sheet) - Modify:
gitbayTests/MRLabelTests.swift(addMRLabelFilterTests) - Modify:
gitbayUITests/LiveSmokeUITests.swift(fixture andtestMRLabelFlow)
Interfaces:
-
Consumes:
LabelFilterSection(Task 4),until(_:)fromgitbayTests/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(twoMARKETING_VERSIONand twoCURRENT_PROJECT_VERSIONlines) -
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".