# 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 `MainActor` isolation. Wire models are `nonisolated struct`s. - Swift Testing only — never XCTest. - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. - The label model is `IssueLabel`; the notification model is `InboxNotification`. Neither shadows a framework type. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. - Never commit to `main`. **The commands, verbatim from the registry:** ``` build cancel release delete --yes org create org rename ``` **`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: ```bash 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() async` and `var isCancellable: Bool` - `ReleaseDetailViewModel.delete() async -> Bool` (true on success, so the view can pop) - `OrgViewModel.rename(to newName: String) async -> Bool` - `ProfileViewModel.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: 1. `cancel()` sends `["build", "cancel", repoPath, String(number)]`. 2. `isCancellable` is true only for status `pending` or `running`. **The wire value is `pending`** — matching on `"queued"` would leave the action permanently hidden, silently. 3. `delete()` sends `["release", "delete", repoPath, tag, "--yes"]`. The `--yes` is required by the command; a delete without it is a usage error. 4. `rename(to:)` sends `["org", "rename", oldName, newName]` — both positional, old first. Swapping them renames the wrong way with no error. 5. `createOrg(_:)` sends `["org", "create", name]`. 6. Every one trims its input and refuses to send a blank name or tag. 7. A refusal surfaces into `actionError`, returns false, and does not pretend success. - [ ] **Step 1: Write the failing tests** ```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 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:)` — note `orgName:`, not `org:` - `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 delete` and `create, rename` (Organizations) to `yes` for iOS. Touch no other row — in particular leave `delete, transfer` and `API token mint`, which are SSH-only by design, and `dependency 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.