docs/superpowers/plans/2026-09-06-mr08-small-actions.md
268 lines · 13159 bytes
7 symbols in this file
MR 8: Build cancel, release delete, org create and rename 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: Close three parity rows — build cancel, release delete, and org create, rename.
Architecture: Three unrelated one-command actions, batched into one merge request because each is a single method plus a menu item on a screen that already has a write path. They share no code; they share only a shape.
Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md
Global Constraints
- Swift 6 language mode, default
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel; the notification model isInboxNotification. Neither shadows a framework type. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - Never commit to
main.
The commands, verbatim from the registry:
build cancel <owner/name> <n>
release delete <owner/name> <tag> --yes
org create <name>
org rename <old> <new>
release delete requires --yes. It is not optional; omitting it is a usage error. That flag is the server asking for a typed confirmation, so the app confirms before sending it.
build cancel is refused unless the build is pending or running (internal/control/build.go:750). The refusal reads "build N is ; only a queued or running build can be cancelled". Note the wire status is pending, even though the prose says "queued" — match on pending, not queued.
Counting tests — use xcresulttool, not grep. Grepping the build log undercounts by one, because xcodebuild splits a result line under parallel output:
RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
xcrun xcresulttool get test-results summary --path "$RES"
Baseline: 311 total, 310 passed, 1 skipped, 0 failed.
File Structure
| File | Responsibility |
|---|---|
gitbay/Builds/BuildDetailViewModel.swift (modify) |
cancel() |
gitbay/Releases/ReleaseViewModels.swift (modify) |
delete() |
gitbay/Orgs/OrgViewModels.swift (modify) |
createOrg(_:), rename(to:) |
gitbayTests/SmallActionTests.swift (create) |
Every test in this plan |
gitbay/Views/Builds/BuildDetailView.swift (modify) |
Cancel action |
gitbay/Views/Releases/ReleaseView.swift (modify) |
Delete action |
gitbay/Views/Orgs/OrgView.swift (modify) |
Rename action |
gitbay/Discovery/ProfileViewModel.swift (modify) |
createOrg(_:) |
gitbay/Views/Discovery/ProfileView.swift (modify) |
Create action |
Task 1: The four methods
Files: Modify the three view models above; test in gitbayTests/SmallActionTests.swift
Interfaces produced:
BuildDetailViewModel.cancel() asyncandvar isCancellable: BoolReleaseDetailViewModel.delete() async -> Bool(true on success, so the view can pop)OrgViewModel.rename(to newName: String) async -> BoolProfileViewModel.createOrg(_ name: String) async -> Bool
Each routes through the existing perform(argv:) in its file. Copy the pattern, do not extract a shared helper — that is a standing decision in this codebase, and these are three unrelated files.
Behaviour, each pinned by a test:
cancel()sends["build", "cancel", repoPath, String(number)].isCancellableis true only for statuspendingorrunning. The wire value ispending— matching on"queued"would leave the action permanently hidden, silently.delete()sends["release", "delete", repoPath, tag, "--yes"]. The--yesis required by the command; a delete without it is a usage error.rename(to:)sends["org", "rename", oldName, newName]— both positional, old first. Swapping them renames the wrong way with no error.createOrg(_:)sends["org", "create", name].- Every one trims its input and refuses to send a blank name or tag.
- A refusal surfaces into
actionError, returns false, and does not pretend success.
- Step 1: Write the failing tests
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 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])
}
private let ok = """
{"protocol_version":1,"exit_code":0}
"""
@MainActor
struct BuildCancelTests {
/// The wire status is "pending", not "queued" — the refusal message
/// says "queued" but the value never does.
@Test func onlyPendingAndRunningBuildsAreCancellable() {
#expect(BuildDetailViewModel.isCancellable(status: "pending"))
#expect(BuildDetailViewModel.isCancellable(status: "running"))
for status in ["success", "failure", "cancelled", "queued", ""] {
#expect(BuildDetailViewModel.isCancellable(status: status) == false,
"\(status) should not be cancellable")
}
}
@Test func cancelSendsTheBuildNumber() async throws {
let (client, stub) = try makeClient()
// The view model's own load response, then the write, then a reload.
let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
stub.enqueue(.init(status: 200, json: ok))
stub.enqueue(.init(status: 200, json: ok))
await model.cancel()
let write = try #require(stub.seen.first { $0.method == "POST" })
#expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"])
}
}
@MainActor
struct ReleaseDeleteTests {
/// `--yes` is required by the command, not optional.
@Test func deleteSendsTheConfirmationFlag() async throws {
let (client, stub) = try makeClient()
let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
stub.enqueue(.init(status: 200, json: ok))
let deleted = await model.delete()
#expect(deleted)
let write = try #require(stub.seen.first { $0.method == "POST" })
#expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"])
}
@Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws {
let (client, stub) = try makeClient()
let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
stub.enqueue(.init(status: 200, json: """
{"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
"""))
let deleted = await model.delete()
#expect(deleted == false)
#expect(model.actionError?.isEmpty == false)
}
}
@MainActor
struct OrgCreateRenameTests {
/// Both names are positional, OLD first. Swapping them renames the
/// wrong way with no error at all.
@Test func renameSendsOldThenNew() async throws {
let (client, stub) = try makeClient()
let model = OrgViewModel(client: client, orgName: "krz")
stub.enqueue(.init(status: 200, json: ok))
stub.enqueue(.init(status: 200, json: ok))
_ = await model.rename(to: "kerouac")
let write = try #require(stub.seen.first { $0.method == "POST" })
#expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"])
}
@Test func aBlankRenameSendsNothing() async throws {
let (client, stub) = try makeClient()
let model = OrgViewModel(client: client, orgName: "krz")
let renamed = await model.rename(to: " ")
#expect(renamed == false)
#expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
}
@Test func createSendsTheName() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: ok))
stub.enqueue(.init(status: 200, json: ok))
let model = ProfileViewModel(client: client, name: "cmc")
_ = await model.createOrg("newco")
let write = try #require(stub.seen.first { $0.method == "POST" })
#expect(try argvOf(write) == ["org", "create", "newco"])
}
@Test func aBlankCreateSendsNothing() async throws {
let (client, stub) = try makeClient()
let model = ProfileViewModel(client: client, name: "cmc")
let created = await model.createOrg(" ")
#expect(created == false)
#expect(stub.seen.filter { $0.method == "POST" }.isEmpty)
}
}
The real initialisers, which I checked — match them exactly and do not change any to suit a test:
BuildDetailViewModel(client:repoPath:number:)ReleaseDetailViewModel(client:repoPath:tag:)OrgViewModel(client:orgName:)— noteorgName:, notorg:ProfileViewModel(client:name:)
There is no organisation list view model. Organisations are listed on the profile screen, from ProfileViewModel.Profile.orgs (ProfileView.swift:28,99), so createOrg belongs on ProfileViewModel and the button belongs on that screen. Check ProfileViewModel's actual initialiser before writing the test.
isCancellable is written above as a static taking a status so it is testable without a loaded model; an instance property derived from the loaded build is equally fine as long as the pending/running/everything-else distinction is directly testable.
- Step 2: Run to verify failure.
- Step 3: Implement the four methods.
- Step 4: Run the tests.
- Step 5: Commit —
git commit -m "Cancel a build, delete a release, create and rename an organisation"
Task 2: The four actions in their screens
Files: the four view files above
No unit tests — UI.
- Build cancel — on the build detail screen, offered only when
isCancellable. Confirm first: cancelling a running build kills the step within a couple of seconds. Say what it does rather than just "Cancel". - Release delete — on the release detail screen, destructive, confirmed, and the confirmation must say the assets go too. Pop back to the release list on success.
- Org rename — on the organisation screen. The confirmation must say clone URLs change; the command's own usage says so, and a rename that silently breaks everyone's remotes is the worst outcome here.
- Org create — on the profile screen, which is where organisations are listed. A name prompt and the action, then reload so the new organisation appears.
Reuse each screen's existing notice mechanism; do not add a second one. Match how each screen already presents its destructive actions — the repository screen's archive and the label screen's remove are the closest precedents.
- Step 1: Build cancel, gated on
isCancellable - Step 2: Release delete with its confirmation and pop
- Step 3: Org rename with the clone-URL warning
- Step 4: Org create
- Step 5: Build and run the full suite. No drop from 310 passed.
- Step 6: Commit —
git commit -m "Cancel, delete and organisation actions in their screens"
Task 3: Flip three parity rows and open the merge request
- Step 1: Set
build cancel,release deleteandcreate, rename(Organizations) toyesfor iOS. Touch no other row — in particular leavedelete, transferandAPI token mint, which are SSH-only by design, anddependency checks/dependency status, which are the next merge request.
Land it via a worktree off origin/main in krz/gitbay, merged --strategy ff. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. Read the merge request number back from mr create's JSON rather than assuming it.
- Step 2: Run the full suite via
xcresulttool; record the real numbers. - Step 3: Open the merge request.
Notes for whoever executes this
The build status is pending, not queued. The server's own refusal message says "queued or running", but the value it compares is pending. Matching on "queued" would hide the cancel action forever, silently.
--yes is not optional on release delete. The command requires it.
org rename is old-then-new, both positional. Reversed, it renames the wrong way and the server cannot tell.
Three unrelated files. They share a shape, not code. Copy each file's own perform; do not unify them.