docs/superpowers/plans/2026-09-06-mr08-small-actions.md

v1.14.1
gitbay-ios/docs/superpowers/plans/2026-09-06-mr08-small-actions.md rendered · source · history · blame · raw

268 lines · 13159 bytes

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 structs.
  • 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   <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() 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
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.