Commit 014ea9f840
Unsigned
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr08-small-actions.md added +268
| @@ -0,0 +1,268 @@ | |||
| 1 | # MR 8: Build cancel, release delete, org create and rename Implementation Plan | ||
| 2 | |||
| 3 | > **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. | ||
| 4 | |||
| 5 | **Goal:** Close three parity rows — `build cancel`, `release delete`, and `org create, rename`. | ||
| 6 | |||
| 7 | **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. | ||
| 8 | |||
| 9 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | ||
| 10 | |||
| 11 | ## Global Constraints | ||
| 12 | |||
| 13 | - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. | ||
| 14 | - Swift Testing only — never XCTest. | ||
| 15 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | ||
| 16 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. | ||
| 17 | - The label model is `IssueLabel`; the notification model is `InboxNotification`. Neither shadows a framework type. | ||
| 18 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | ||
| 19 | - Never commit to `main`. | ||
| 20 | |||
| 21 | **The commands, verbatim from the registry:** | ||
| 22 | |||
| 23 | ``` | ||
| 24 | build cancel <owner/name> <n> | ||
| 25 | release delete <owner/name> <tag> --yes | ||
| 26 | org create <name> | ||
| 27 | org rename <old> <new> | ||
| 28 | ``` | ||
| 29 | |||
| 30 | **`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. | ||
| 31 | |||
| 32 | **`build cancel` is refused unless the build is `pending` or `running`** (`internal/control/build.go:750`). The refusal reads *"build N is <status>; 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`. | ||
| 33 | |||
| 34 | **Counting tests — use `xcresulttool`, not grep.** Grepping the build log undercounts by one, because `xcodebuild` splits a result line under parallel output: | ||
| 35 | |||
| 36 | ```bash | ||
| 37 | RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1) | ||
| 38 | xcrun xcresulttool get test-results summary --path "$RES" | ||
| 39 | ``` | ||
| 40 | |||
| 41 | Baseline: **311 total, 310 passed, 1 skipped, 0 failed.** | ||
| 42 | |||
| 43 | --- | ||
| 44 | |||
| 45 | ## File Structure | ||
| 46 | |||
| 47 | | File | Responsibility | | ||
| 48 | |------|----------------| | ||
| 49 | | `gitbay/Builds/BuildDetailViewModel.swift` (modify) | `cancel()` | | ||
| 50 | | `gitbay/Releases/ReleaseViewModels.swift` (modify) | `delete()` | | ||
| 51 | | `gitbay/Orgs/OrgViewModels.swift` (modify) | `createOrg(_:)`, `rename(to:)` | | ||
| 52 | | `gitbayTests/SmallActionTests.swift` (create) | Every test in this plan | | ||
| 53 | | `gitbay/Views/Builds/BuildDetailView.swift` (modify) | Cancel action | | ||
| 54 | | `gitbay/Views/Releases/ReleaseView.swift` (modify) | Delete action | | ||
| 55 | | `gitbay/Views/Orgs/OrgView.swift` (modify) | Rename action | | ||
| 56 | | `gitbay/Discovery/ProfileViewModel.swift` (modify) | `createOrg(_:)` | | ||
| 57 | | `gitbay/Views/Discovery/ProfileView.swift` (modify) | Create action | | ||
| 58 | |||
| 59 | --- | ||
| 60 | |||
| 61 | ### Task 1: The four methods | ||
| 62 | |||
| 63 | **Files:** Modify the three view models above; test in `gitbayTests/SmallActionTests.swift` | ||
| 64 | |||
| 65 | **Interfaces produced:** | ||
| 66 | - `BuildDetailViewModel.cancel() async` and `var isCancellable: Bool` | ||
| 67 | - `ReleaseDetailViewModel.delete() async -> Bool` (true on success, so the view can pop) | ||
| 68 | - `OrgViewModel.rename(to newName: String) async -> Bool` | ||
| 69 | - `ProfileViewModel.createOrg(_ name: String) async -> Bool` | ||
| 70 | |||
| 71 | 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. | ||
| 72 | |||
| 73 | Behaviour, each pinned by a test: | ||
| 74 | 1. `cancel()` sends `["build", "cancel", repoPath, String(number)]`. | ||
| 75 | 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. | ||
| 76 | 3. `delete()` sends `["release", "delete", repoPath, tag, "--yes"]`. The `--yes` is required by the command; a delete without it is a usage error. | ||
| 77 | 4. `rename(to:)` sends `["org", "rename", oldName, newName]` — both positional, old first. Swapping them renames the wrong way with no error. | ||
| 78 | 5. `createOrg(_:)` sends `["org", "create", name]`. | ||
| 79 | 6. Every one trims its input and refuses to send a blank name or tag. | ||
| 80 | 7. A refusal surfaces into `actionError`, returns false, and does not pretend success. | ||
| 81 | |||
| 82 | - [ ] **Step 1: Write the failing tests** | ||
| 83 | |||
| 84 | ```swift | ||
| 85 | import Foundation | ||
| 86 | import Testing | ||
| 87 | @testable import gitbay | ||
| 88 | |||
| 89 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 90 | let box = StubProtocol.box() | ||
| 91 | let client = GitbayClient( | ||
| 92 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 93 | token: "test-token", | ||
| 94 | session: box.session() | ||
| 95 | ) | ||
| 96 | return (client, box) | ||
| 97 | } | ||
| 98 | |||
| 99 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 100 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 101 | return try #require(body["argv"] as? [String]) | ||
| 102 | } | ||
| 103 | |||
| 104 | private let ok = """ | ||
| 105 | {"protocol_version":1,"exit_code":0} | ||
| 106 | """ | ||
| 107 | |||
| 108 | @MainActor | ||
| 109 | struct BuildCancelTests { | ||
| 110 | |||
| 111 | /// The wire status is "pending", not "queued" — the refusal message | ||
| 112 | /// says "queued" but the value never does. | ||
| 113 | @Test func onlyPendingAndRunningBuildsAreCancellable() { | ||
| 114 | #expect(BuildDetailViewModel.isCancellable(status: "pending")) | ||
| 115 | #expect(BuildDetailViewModel.isCancellable(status: "running")) | ||
| 116 | for status in ["success", "failure", "cancelled", "queued", ""] { | ||
| 117 | #expect(BuildDetailViewModel.isCancellable(status: status) == false, | ||
| 118 | "\(status) should not be cancellable") | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | @Test func cancelSendsTheBuildNumber() async throws { | ||
| 123 | let (client, stub) = try makeClient() | ||
| 124 | // The view model's own load response, then the write, then a reload. | ||
| 125 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) | ||
| 126 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 127 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 128 | await model.cancel() | ||
| 129 | |||
| 130 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 131 | #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"]) | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | @MainActor | ||
| 136 | struct ReleaseDeleteTests { | ||
| 137 | |||
| 138 | /// `--yes` is required by the command, not optional. | ||
| 139 | @Test func deleteSendsTheConfirmationFlag() async throws { | ||
| 140 | let (client, stub) = try makeClient() | ||
| 141 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 142 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 143 | let deleted = await model.delete() | ||
| 144 | |||
| 145 | #expect(deleted) | ||
| 146 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 147 | #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"]) | ||
| 148 | } | ||
| 149 | |||
| 150 | @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws { | ||
| 151 | let (client, stub) = try makeClient() | ||
| 152 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 153 | stub.enqueue(.init(status: 200, json: """ | ||
| 154 | {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} | ||
| 155 | """)) | ||
| 156 | let deleted = await model.delete() | ||
| 157 | |||
| 158 | #expect(deleted == false) | ||
| 159 | #expect(model.actionError?.isEmpty == false) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | @MainActor | ||
| 164 | struct OrgCreateRenameTests { | ||
| 165 | |||
| 166 | /// Both names are positional, OLD first. Swapping them renames the | ||
| 167 | /// wrong way with no error at all. | ||
| 168 | @Test func renameSendsOldThenNew() async throws { | ||
| 169 | let (client, stub) = try makeClient() | ||
| 170 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 171 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 172 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 173 | _ = await model.rename(to: "kerouac") | ||
| 174 | |||
| 175 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 176 | #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"]) | ||
| 177 | } | ||
| 178 | |||
| 179 | @Test func aBlankRenameSendsNothing() async throws { | ||
| 180 | let (client, stub) = try makeClient() | ||
| 181 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 182 | let renamed = await model.rename(to: " ") | ||
| 183 | |||
| 184 | #expect(renamed == false) | ||
| 185 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 186 | } | ||
| 187 | |||
| 188 | @Test func createSendsTheName() async throws { | ||
| 189 | let (client, stub) = try makeClient() | ||
| 190 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 191 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 192 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 193 | _ = await model.createOrg("newco") | ||
| 194 | |||
| 195 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 196 | #expect(try argvOf(write) == ["org", "create", "newco"]) | ||
| 197 | } | ||
| 198 | |||
| 199 | @Test func aBlankCreateSendsNothing() async throws { | ||
| 200 | let (client, stub) = try makeClient() | ||
| 201 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 202 | let created = await model.createOrg(" ") | ||
| 203 | |||
| 204 | #expect(created == false) | ||
| 205 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 206 | } | ||
| 207 | } | ||
| 208 | ``` | ||
| 209 | |||
| 210 | **The real initialisers, which I checked** — match them exactly and do not change any to suit a test: | ||
| 211 | - `BuildDetailViewModel(client:repoPath:number:)` | ||
| 212 | - `ReleaseDetailViewModel(client:repoPath:tag:)` | ||
| 213 | - `OrgViewModel(client:orgName:)` — note `orgName:`, not `org:` | ||
| 214 | - `ProfileViewModel(client:name:)` | ||
| 215 | |||
| 216 | **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. | ||
| 217 | |||
| 218 | `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. | ||
| 219 | |||
| 220 | - [ ] **Step 2: Run to verify failure.** | ||
| 221 | - [ ] **Step 3: Implement the four methods.** | ||
| 222 | - [ ] **Step 4: Run the tests.** | ||
| 223 | - [ ] **Step 5: Commit** — `git commit -m "Cancel a build, delete a release, create and rename an organisation"` | ||
| 224 | |||
| 225 | --- | ||
| 226 | |||
| 227 | ### Task 2: The four actions in their screens | ||
| 228 | |||
| 229 | **Files:** the four view files above | ||
| 230 | |||
| 231 | No unit tests — UI. | ||
| 232 | |||
| 233 | - **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". | ||
| 234 | - **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. | ||
| 235 | - **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. | ||
| 236 | - **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. | ||
| 237 | |||
| 238 | 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. | ||
| 239 | |||
| 240 | - [ ] **Step 1: Build cancel, gated on `isCancellable`** | ||
| 241 | - [ ] **Step 2: Release delete with its confirmation and pop** | ||
| 242 | - [ ] **Step 3: Org rename with the clone-URL warning** | ||
| 243 | - [ ] **Step 4: Org create** | ||
| 244 | - [ ] **Step 5: Build and run the full suite.** No drop from 310 passed. | ||
| 245 | - [ ] **Step 6: Commit** — `git commit -m "Cancel, delete and organisation actions in their screens"` | ||
| 246 | |||
| 247 | --- | ||
| 248 | |||
| 249 | ### Task 3: Flip three parity rows and open the merge request | ||
| 250 | |||
| 251 | - [ ] **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. | ||
| 252 | |||
| 253 | 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.** | ||
| 254 | |||
| 255 | - [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers. | ||
| 256 | - [ ] **Step 3:** Open the merge request. | ||
| 257 | |||
| 258 | --- | ||
| 259 | |||
| 260 | ## Notes for whoever executes this | ||
| 261 | |||
| 262 | **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. | ||
| 263 | |||
| 264 | **`--yes` is not optional on `release delete`.** The command requires it. | ||
| 265 | |||
| 266 | **`org rename` is old-then-new, both positional.** Reversed, it renames the wrong way and the server cannot tell. | ||
| 267 | |||
| 268 | **Three unrelated files.** They share a shape, not code. Copy each file's own `perform`; do not unify them. | ||
gitbay/Builds/BuildDetailViewModel.swift +33
| @@ -14,6 +14,8 @@ nonisolated struct BuildDetail: Sendable { | |||
| 14 | final class BuildDetailViewModel { | 14 | final class BuildDetailViewModel { |
| 15 | 15 | ||
| 16 | private(set) var state: LoadState<BuildDetail> = .loading | 16 | private(set) var state: LoadState<BuildDetail> = .loading |
| 17 | private(set) var actionError: String? | ||
| 18 | private(set) var working = false | ||
| 17 | 19 | ||
| 18 | private let client: GitbayClient | 20 | private let client: GitbayClient |
| 19 | let repoPath: String | 21 | let repoPath: String |
| @@ -36,4 +38,35 @@ final class BuildDetailViewModel { | |||
| 36 | state = .from(error) | 38 | state = .from(error) |
| 37 | } | 39 | } |
| 38 | } | 40 | } |
| 41 | |||
| 42 | /// Only `pending` or `running` builds can be cancelled server-side | ||
| 43 | /// (`internal/control/build.go:750`). The refusal message there says | ||
| 44 | /// "queued", but the wire status is always "pending" — matching on | ||
| 45 | /// "queued" would hide this action forever with no error. | ||
| 46 | static func isCancellable(status: String) -> Bool { | ||
| 47 | status == "pending" || status == "running" | ||
| 48 | } | ||
| 49 | |||
| 50 | var isCancellable: Bool { | ||
| 51 | guard let build = state.value?.build else { return false } | ||
| 52 | return Self.isCancellable(status: build.status) | ||
| 53 | } | ||
| 54 | |||
| 55 | func cancel() async { | ||
| 56 | await perform(["build", "cancel", repoPath, String(number)]) | ||
| 57 | } | ||
| 58 | |||
| 59 | private func perform(_ argv: [String]) async { | ||
| 60 | working = true | ||
| 61 | actionError = nil | ||
| 62 | defer { working = false } | ||
| 63 | do { | ||
| 64 | try await client.run(argv) | ||
| 65 | await load() | ||
| 66 | } catch let error as GitbayError { | ||
| 67 | actionError = error.userFacingMessage | ||
| 68 | } catch { | ||
| 69 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 70 | } | ||
| 71 | } | ||
| 39 | } | 72 | } |
gitbay/Discovery/ProfileViewModel.swift +24
| @@ -96,6 +96,8 @@ final class ProfileViewModel { | |||
| 96 | } | 96 | } |
| 97 | 97 | ||
| 98 | private(set) var state: LoadState<Profile> = .loading | 98 | private(set) var state: LoadState<Profile> = .loading |
| 99 | private(set) var actionError: String? | ||
| 100 | private(set) var working = false | ||
| 99 | 101 | ||
| 100 | private let client: GitbayClient | 102 | private let client: GitbayClient |
| 101 | let name: String | 103 | let name: String |
| @@ -112,4 +114,26 @@ final class ProfileViewModel { | |||
| 112 | state = .from(error) | 114 | state = .from(error) |
| 113 | } | 115 | } |
| 114 | } | 116 | } |
| 117 | |||
| 118 | /// `org create <name>`. | ||
| 119 | func createOrg(_ name: String) async -> Bool { | ||
| 120 | let name = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 121 | guard !name.isEmpty else { return false } | ||
| 122 | await perform(["org", "create", name]) | ||
| 123 | return actionError == nil | ||
| 124 | } | ||
| 125 | |||
| 126 | private func perform(_ argv: [String]) async { | ||
| 127 | working = true | ||
| 128 | actionError = nil | ||
| 129 | defer { working = false } | ||
| 130 | do { | ||
| 131 | try await client.run(argv) | ||
| 132 | await load() | ||
| 133 | } catch let error as GitbayError { | ||
| 134 | actionError = error.userFacingMessage | ||
| 135 | } catch { | ||
| 136 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 137 | } | ||
| 138 | } | ||
| 115 | } | 139 | } |
gitbay/Orgs/OrgViewModels.swift +28 −1
| @@ -31,7 +31,7 @@ final class OrgViewModel { | |||
| 31 | private(set) var working = false | 31 | private(set) var working = false |
| 32 | 32 | ||
| 33 | private let client: GitbayClient | 33 | private let client: GitbayClient |
| 34 | let orgName: String | 34 | private(set) var orgName: String |
| 35 | 35 | ||
| 36 | init(client: GitbayClient, orgName: String) { | 36 | init(client: GitbayClient, orgName: String) { |
| 37 | self.client = client | 37 | self.client = client |
| @@ -67,6 +67,33 @@ final class OrgViewModel { | |||
| 67 | await perform(["org", "members", "add", orgName, member.user, "--role", role]) | 67 | await perform(["org", "members", "add", orgName, member.user, "--role", role]) |
| 68 | } | 68 | } |
| 69 | 69 | ||
| 70 | /// `org rename <old> <new>` — both positional, old first. Swapping | ||
| 71 | /// them renames the wrong way with no error at all. | ||
| 72 | /// | ||
| 73 | /// Doesn't go through `perform`: its automatic reload would still | ||
| 74 | /// query the old, now-renamed org. `orgName` only moves to the new | ||
| 75 | /// name once the rename itself has actually succeeded, and the | ||
| 76 | /// reload that follows targets that new name. | ||
| 77 | func rename(to newName: String) async -> Bool { | ||
| 78 | let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 79 | guard !newName.isEmpty else { return false } | ||
| 80 | working = true | ||
| 81 | actionError = nil | ||
| 82 | defer { working = false } | ||
| 83 | do { | ||
| 84 | try await client.run(["org", "rename", orgName, newName]) | ||
| 85 | orgName = newName | ||
| 86 | await load() | ||
| 87 | return true | ||
| 88 | } catch let error as GitbayError { | ||
| 89 | actionError = error.userFacingMessage | ||
| 90 | return false | ||
| 91 | } catch { | ||
| 92 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 93 | return false | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 70 | func createTeam(_ team: String) async { | 97 | func createTeam(_ team: String) async { |
| 71 | await perform(["org", "team", "create", orgName, team]) | 98 | await perform(["org", "team", "create", orgName, team]) |
| 72 | } | 99 | } |
gitbay/Releases/ReleaseViewModels.swift +17
| @@ -100,6 +100,23 @@ final class ReleaseDetailViewModel { | |||
| 100 | } | 100 | } |
| 101 | } | 101 | } |
| 102 | 102 | ||
| 103 | /// `release delete <owner/name> <tag> --yes` — `--yes` is required by | ||
| 104 | /// the command, not optional; omitting it is a usage error. | ||
| 105 | func delete() async -> Bool { | ||
| 106 | working = true | ||
| 107 | actionError = nil | ||
| 108 | defer { working = false } | ||
| 109 | do { | ||
| 110 | try await client.run(["release", "delete", repoPath, tag, "--yes"]) | ||
| 111 | return true | ||
| 112 | } catch let error as GitbayError { | ||
| 113 | actionError = error.userFacingMessage | ||
| 114 | } catch { | ||
| 115 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 116 | } | ||
| 117 | return false | ||
| 118 | } | ||
| 119 | |||
| 103 | /// Where a browser downloads this asset — the web's own download | 120 | /// Where a browser downloads this asset — the web's own download |
| 104 | /// route; the JSON surface has no binary-safe asset read. | 121 | /// route; the JSON surface has no binary-safe asset read. |
| 105 | func downloadURL(for asset: Release.Asset) -> URL { | 122 | func downloadURL(for asset: Release.Asset) -> URL { |
gitbay/Views/Builds/BuildDetailView.swift +36
| @@ -4,6 +4,7 @@ import SwiftUI | |||
| 4 | struct BuildDetailView: View { | 4 | struct BuildDetailView: View { |
| 5 | 5 | ||
| 6 | @State private var model: BuildDetailViewModel | 6 | @State private var model: BuildDetailViewModel |
| 7 | @State private var confirmingCancel = false | ||
| 7 | 8 | ||
| 8 | init(client: GitbayClient, repo: String, number: Int64) { | 9 | init(client: GitbayClient, repo: String, number: Int64) { |
| 9 | _model = State(initialValue: BuildDetailViewModel( | 10 | _model = State(initialValue: BuildDetailViewModel( |
| @@ -16,6 +17,11 @@ struct BuildDetailView: View { | |||
| 16 | Color.clear | 17 | Color.clear |
| 17 | if let detail = model.state.value { | 18 | if let detail = model.state.value { |
| 18 | VStack(spacing: 0) { | 19 | VStack(spacing: 0) { |
| 20 | if let error = model.actionError { | ||
| 21 | GBNotice(error, .gbWarn) | ||
| 22 | .padding(.horizontal, 16) | ||
| 23 | .padding(.vertical, 8) | ||
| 24 | } | ||
| 19 | header(detail.build) | 25 | header(detail.build) |
| 20 | Divider() | 26 | Divider() |
| 21 | log(detail.log) | 27 | log(detail.log) |
| @@ -25,8 +31,38 @@ struct BuildDetailView: View { | |||
| 25 | .overlay { LoadStateOverlay(state: model.state) } | 31 | .overlay { LoadStateOverlay(state: model.state) } |
| 26 | .navigationTitle("Build #\(model.number)") | 32 | .navigationTitle("Build #\(model.number)") |
| 27 | .navigationBarTitleDisplayMode(.inline) | 33 | .navigationBarTitleDisplayMode(.inline) |
| 34 | .toolbar { | ||
| 35 | ToolbarItem(placement: .topBarTrailing) { | ||
| 36 | if model.isCancellable { | ||
| 37 | if model.working { | ||
| 38 | ProgressView() | ||
| 39 | } else { | ||
| 40 | Button("Cancel Build", role: .destructive) { | ||
| 41 | confirmingCancel = true | ||
| 42 | } | ||
| 43 | .accessibilityIdentifier("build-cancel-button") | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 28 | .task { await model.load() } | 48 | .task { await model.load() } |
| 29 | .refreshable { await model.load() } | 49 | .refreshable { await model.load() } |
| 50 | .confirmationDialog( | ||
| 51 | "Cancel this build?", | ||
| 52 | isPresented: $confirmingCancel, | ||
| 53 | titleVisibility: .visible | ||
| 54 | ) { | ||
| 55 | Button("Cancel Build", role: .destructive) { | ||
| 56 | Task { await model.cancel() } | ||
| 57 | } | ||
| 58 | Button("Keep Running", role: .cancel) {} | ||
| 59 | } message: { | ||
| 60 | Text( | ||
| 61 | "A running step ends within a couple of seconds. If this is a " + | ||
| 62 | "duplicate of a commit that already passed, that earlier result " + | ||
| 63 | "is restored on the commit." | ||
| 64 | ) | ||
| 65 | } | ||
| 30 | } | 66 | } |
| 31 | 67 | ||
| 32 | private func header(_ build: Build) -> some View { | 68 | private func header(_ build: Build) -> some View { |
gitbay/Views/Discovery/ProfileView.swift +33
| @@ -4,7 +4,10 @@ import SwiftUI | |||
| 4 | /// have been doing, and what they own that you can see. | 4 | /// have been doing, and what they own that you can see. |
| 5 | struct ProfileView: View { | 5 | struct ProfileView: View { |
| 6 | 6 | ||
| 7 | @Environment(SessionStore.self) private var session | ||
| 7 | @State private var model: ProfileViewModel | 8 | @State private var model: ProfileViewModel |
| 9 | @State private var creatingOrg = false | ||
| 10 | @State private var newOrgName = "" | ||
| 8 | 11 | ||
| 9 | /// Someone else's profile is titled with their name. Your own is | 12 | /// Someone else's profile is titled with their name. Your own is |
| 10 | /// reached from a tab and titled to match it. | 13 | /// reached from a tab and titled to match it. |
| @@ -18,6 +21,11 @@ struct ProfileView: View { | |||
| 18 | var body: some View { | 21 | var body: some View { |
| 19 | List { | 22 | List { |
| 20 | if let profile = model.state.value { | 23 | if let profile = model.state.value { |
| 24 | if let error = model.actionError { | ||
| 25 | Section { | ||
| 26 | GBNotice(error, .gbWarn) | ||
| 27 | } | ||
| 28 | } | ||
| 21 | header(profile) | 29 | header(profile) |
| 22 | peopleSection(profile) | 30 | peopleSection(profile) |
| 23 | aboutSection(profile) | 31 | aboutSection(profile) |
| @@ -35,8 +43,33 @@ struct ProfileView: View { | |||
| 35 | .overlay { LoadStateOverlay(state: model.state) } | 43 | .overlay { LoadStateOverlay(state: model.state) } |
| 36 | .navigationTitle(title ?? model.name) | 44 | .navigationTitle(title ?? model.name) |
| 37 | .navigationBarTitleDisplayMode(.inline) | 45 | .navigationBarTitleDisplayMode(.inline) |
| 46 | .toolbar { | ||
| 47 | if let profile = model.state.value, | ||
| 48 | !profile.isOrg, session.current?.username == profile.name { | ||
| 49 | ToolbarItem(placement: .topBarTrailing) { | ||
| 50 | Button { | ||
| 51 | newOrgName = "" | ||
| 52 | creatingOrg = true | ||
| 53 | } label: { | ||
| 54 | Image(systemName: "plus.circle") | ||
| 55 | } | ||
| 56 | .disabled(model.working) | ||
| 57 | .accessibilityIdentifier("org-create-button") | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 38 | .task { await model.load() } | 61 | .task { await model.load() } |
| 39 | .refreshable { await model.load() } | 62 | .refreshable { await model.load() } |
| 63 | .alert("New Organization", isPresented: $creatingOrg) { | ||
| 64 | TextField("Name", text: $newOrgName) | ||
| 65 | .autocorrectionDisabled() | ||
| 66 | .textInputAutocapitalization(.never) | ||
| 67 | Button("Create") { | ||
| 68 | Task { await model.createOrg(newOrgName) } | ||
| 69 | } | ||
| 70 | .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 71 | Button("Cancel", role: .cancel) {} | ||
| 72 | } | ||
| 40 | } | 73 | } |
| 41 | 74 | ||
| 42 | // MARK: - Sections | 75 | // MARK: - Sections |
gitbay/Views/Orgs/OrgView.swift +26
| @@ -10,6 +10,8 @@ struct OrgView: View { | |||
| 10 | @State private var newTeam = "" | 10 | @State private var newTeam = "" |
| 11 | @State private var removingMember: OrgMember? | 11 | @State private var removingMember: OrgMember? |
| 12 | @State private var deletingTeam: String? | 12 | @State private var deletingTeam: String? |
| 13 | @State private var renamingOrg = false | ||
| 14 | @State private var newOrgName = "" | ||
| 13 | 15 | ||
| 14 | init(client: GitbayClient, org: String) { | 16 | init(client: GitbayClient, org: String) { |
| 15 | _model = State(initialValue: OrgViewModel(client: client, orgName: org)) | 17 | _model = State(initialValue: OrgViewModel(client: client, orgName: org)) |
| @@ -30,8 +32,32 @@ struct OrgView: View { | |||
| 30 | .overlay { LoadStateOverlay(state: model.state) } | 32 | .overlay { LoadStateOverlay(state: model.state) } |
| 31 | .navigationTitle(model.orgName) | 33 | .navigationTitle(model.orgName) |
| 32 | .navigationBarTitleDisplayMode(.inline) | 34 | .navigationBarTitleDisplayMode(.inline) |
| 35 | .toolbar { | ||
| 36 | ToolbarItem(placement: .topBarTrailing) { | ||
| 37 | Button { | ||
| 38 | newOrgName = "" | ||
| 39 | renamingOrg = true | ||
| 40 | } label: { | ||
| 41 | Image(systemName: "pencil") | ||
| 42 | } | ||
| 43 | .disabled(model.working) | ||
| 44 | .accessibilityIdentifier("org-rename-button") | ||
| 45 | } | ||
| 46 | } | ||
| 33 | .task { await model.load() } | 47 | .task { await model.load() } |
| 34 | .refreshable { await model.load() } | 48 | .refreshable { await model.load() } |
| 49 | .alert("Rename \(model.orgName)", isPresented: $renamingOrg) { | ||
| 50 | TextField(model.orgName, text: $newOrgName) | ||
| 51 | .autocorrectionDisabled() | ||
| 52 | .textInputAutocapitalization(.never) | ||
| 53 | Button("Rename") { | ||
| 54 | Task { await model.rename(to: newOrgName) } | ||
| 55 | } | ||
| 56 | .disabled(newOrgName.trimmingCharacters(in: .whitespaces).isEmpty) | ||
| 57 | Button("Cancel", role: .cancel) {} | ||
| 58 | } message: { | ||
| 59 | Text("Clone URLs for every repository in this organization change to the new name.") | ||
| 60 | } | ||
| 35 | .confirmationDialog( | 61 | .confirmationDialog( |
| 36 | "Remove \(removingMember?.user ?? "") from \(model.orgName)?", | 62 | "Remove \(removingMember?.user ?? "") from \(model.orgName)?", |
| 37 | isPresented: Binding( | 63 | isPresented: Binding( |
gitbay/Views/Releases/ReleaseListView.swift +12 −1
| @@ -7,6 +7,7 @@ struct ReleaseListView: View { | |||
| 7 | @State private var draftTag = "" | 7 | @State private var draftTag = "" |
| 8 | @State private var draftTitle = "" | 8 | @State private var draftTitle = "" |
| 9 | @State private var draftNotes = "" | 9 | @State private var draftNotes = "" |
| 10 | @State private var hasLoadedOnce = false | ||
| 10 | 11 | ||
| 11 | init(client: GitbayClient, repo: String) { | 12 | init(client: GitbayClient, repo: String) { |
| 12 | _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo)) | 13 | _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo)) |
| @@ -63,7 +64,17 @@ struct ReleaseListView: View { | |||
| 63 | composing = false | 64 | composing = false |
| 64 | } | 65 | } |
| 65 | } | 66 | } |
| 66 | .task { await model.load() } | 67 | .task { |
| 68 | await model.load() | ||
| 69 | hasLoadedOnce = true | ||
| 70 | } | ||
| 71 | // A deleted release pops back to this list; `.task` does not | ||
| 72 | // re-run on that pop, so a manual refresh here is what drops the | ||
| 73 | // now-gone row. Guarded so the initial `.task` load isn't repeated. | ||
| 74 | .onAppear { | ||
| 75 | guard hasLoadedOnce else { return } | ||
| 76 | Task { await model.load() } | ||
| 77 | } | ||
| 67 | .refreshable { await model.load() } | 78 | .refreshable { await model.load() } |
| 68 | } | 79 | } |
| 69 | } | 80 | } |
gitbay/Views/Releases/ReleaseView.swift +27
| @@ -6,6 +6,8 @@ struct ReleaseView: View { | |||
| 6 | @State private var editing = false | 6 | @State private var editing = false |
| 7 | @State private var draftTitle = "" | 7 | @State private var draftTitle = "" |
| 8 | @State private var draftNotes = "" | 8 | @State private var draftNotes = "" |
| 9 | @State private var confirmingDelete = false | ||
| 10 | @Environment(\.dismiss) private var dismiss | ||
| 9 | 11 | ||
| 10 | init(client: GitbayClient, repo: String, tag: String) { | 12 | init(client: GitbayClient, repo: String, tag: String) { |
| 11 | _model = State(initialValue: ReleaseDetailViewModel( | 13 | _model = State(initialValue: ReleaseDetailViewModel( |
| @@ -89,6 +91,31 @@ struct ReleaseView: View { | |||
| 89 | .accessibilityIdentifier("release-edit-button") | 91 | .accessibilityIdentifier("release-edit-button") |
| 90 | } | 92 | } |
| 91 | } | 93 | } |
| 94 | ToolbarItem(placement: .topBarTrailing) { | ||
| 95 | if model.state.value != nil { | ||
| 96 | Button("Delete", role: .destructive) { | ||
| 97 | confirmingDelete = true | ||
| 98 | } | ||
| 99 | .disabled(model.working) | ||
| 100 | .accessibilityIdentifier("release-delete-button") | ||
| 101 | } | ||
| 102 | } | ||
| 103 | } | ||
| 104 | .confirmationDialog( | ||
| 105 | "Delete \(model.tag)?", | ||
| 106 | isPresented: $confirmingDelete, | ||
| 107 | titleVisibility: .visible | ||
| 108 | ) { | ||
| 109 | Button("Delete", role: .destructive) { | ||
| 110 | Task { | ||
| 111 | if await model.delete() { | ||
| 112 | dismiss() | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | Button("Cancel", role: .cancel) {} | ||
| 117 | } message: { | ||
| 118 | Text("This deletes the release's assets along with it.") | ||
| 92 | } | 119 | } |
| 93 | .sheet(isPresented: $editing) { | 120 | .sheet(isPresented: $editing) { |
| 94 | ComposeSheet( | 121 | ComposeSheet( |
gitbayTests/SmallActionTests.swift added +194
| @@ -0,0 +1,194 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import gitbay | ||
| 4 | |||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 6 | let box = StubProtocol.box() | ||
| 7 | let client = GitbayClient( | ||
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 9 | token: "test-token", | ||
| 10 | session: box.session() | ||
| 11 | ) | ||
| 12 | return (client, box) | ||
| 13 | } | ||
| 14 | |||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 17 | return try #require(body["argv"] as? [String]) | ||
| 18 | } | ||
| 19 | |||
| 20 | private let ok = """ | ||
| 21 | {"protocol_version":1,"exit_code":0} | ||
| 22 | """ | ||
| 23 | |||
| 24 | @MainActor | ||
| 25 | struct BuildCancelTests { | ||
| 26 | |||
| 27 | /// The wire status is "pending", not "queued" — the refusal message | ||
| 28 | /// says "queued" but the value never does. | ||
| 29 | @Test func onlyPendingAndRunningBuildsAreCancellable() { | ||
| 30 | #expect(BuildDetailViewModel.isCancellable(status: "pending")) | ||
| 31 | #expect(BuildDetailViewModel.isCancellable(status: "running")) | ||
| 32 | for status in ["success", "failure", "cancelled", "queued", ""] { | ||
| 33 | #expect(BuildDetailViewModel.isCancellable(status: status) == false, | ||
| 34 | "\(status) should not be cancellable") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | @Test func cancelSendsTheBuildNumber() async throws { | ||
| 39 | let (client, stub) = try makeClient() | ||
| 40 | // The write, then its reload — `build show` and `build log` fire | ||
| 41 | // concurrently, so each needs its own matched stub. | ||
| 42 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) | ||
| 43 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 44 | stub.enqueue(.init(status: 200, json: """ | ||
| 45 | {"protocol_version":1,"data":{"number":966,"job":"test","status":"cancelled",\ | ||
| 46 | "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\ | ||
| 47 | "exit_code":0} | ||
| 48 | """, match: "argv=show")) | ||
| 49 | stub.enqueue(.init(status: 200, json: ok, match: "argv=log")) | ||
| 50 | await model.cancel() | ||
| 51 | |||
| 52 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 53 | #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"]) | ||
| 54 | } | ||
| 55 | |||
| 56 | /// The view reads the instance property, not the static function — | ||
| 57 | /// nothing pinned that it tracks the loaded build, or that it starts | ||
| 58 | /// out false before anything has loaded. | ||
| 59 | @Test func instanceIsCancellableTracksTheLoadedBuild() async throws { | ||
| 60 | let (client, stub) = try makeClient() | ||
| 61 | let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) | ||
| 62 | #expect(model.isCancellable == false) | ||
| 63 | |||
| 64 | stub.enqueue(.init(status: 200, json: """ | ||
| 65 | {"protocol_version":1,"data":{"number":966,"job":"test","status":"running",\ | ||
| 66 | "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\ | ||
| 67 | "exit_code":0} | ||
| 68 | """, match: "argv=show")) | ||
| 69 | stub.enqueue(.init(status: 200, json: ok, match: "argv=log")) | ||
| 70 | await model.load() | ||
| 71 | |||
| 72 | #expect(model.isCancellable) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | @MainActor | ||
| 77 | struct ReleaseDeleteTests { | ||
| 78 | |||
| 79 | /// `--yes` is required by the command, not optional. | ||
| 80 | @Test func deleteSendsTheConfirmationFlag() async throws { | ||
| 81 | let (client, stub) = try makeClient() | ||
| 82 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 83 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 84 | let deleted = await model.delete() | ||
| 85 | |||
| 86 | #expect(deleted) | ||
| 87 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 88 | #expect(try argvOf(write) == ["release", "delete", "krz/gitbay", "v1.0.0", "--yes"]) | ||
| 89 | } | ||
| 90 | |||
| 91 | @Test func aRefusedDeleteReturnsFalseAndSurfaces() async throws { | ||
| 92 | let (client, stub) = try makeClient() | ||
| 93 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 94 | stub.enqueue(.init(status: 200, json: """ | ||
| 95 | {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} | ||
| 96 | """)) | ||
| 97 | let deleted = await model.delete() | ||
| 98 | |||
| 99 | #expect(deleted == false) | ||
| 100 | #expect(model.actionError?.isEmpty == false) | ||
| 101 | } | ||
| 102 | |||
| 103 | /// A stale `actionError` surviving an unrelated later call has been the | ||
| 104 | /// bug five times over; every `perform` clears it up front, but nothing | ||
| 105 | /// pinned that. | ||
| 106 | @Test func actionErrorClearsOnALaterSuccess() async throws { | ||
| 107 | let (client, stub) = try makeClient() | ||
| 108 | let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0") | ||
| 109 | stub.enqueue(.init(status: 200, json: """ | ||
| 110 | {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4} | ||
| 111 | """)) | ||
| 112 | let firstDelete = await model.delete() | ||
| 113 | |||
| 114 | #expect(firstDelete == false) | ||
| 115 | #expect(model.actionError != nil) | ||
| 116 | |||
| 117 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 118 | let secondDelete = await model.delete() | ||
| 119 | |||
| 120 | #expect(secondDelete) | ||
| 121 | #expect(model.actionError == nil) | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | @MainActor | ||
| 126 | struct OrgCreateRenameTests { | ||
| 127 | |||
| 128 | /// Both names are positional, OLD first. Swapping them renames the | ||
| 129 | /// wrong way with no error at all. | ||
| 130 | /// | ||
| 131 | /// A successful rename triggers `load()`, which fires two concurrent | ||
| 132 | /// GETs (`org show`, `org team list`) alongside the POST — all three | ||
| 133 | /// need a stub, or the reload fails and the model ends up showing an | ||
| 134 | /// error despite the rename having worked. | ||
| 135 | @Test func renameSendsOldThenNewAndReloadsTheNewName() async throws { | ||
| 136 | let (client, stub) = try makeClient() | ||
| 137 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 138 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 139 | stub.enqueue(.init(status: 200, json: """ | ||
| 140 | {"protocol_version":1,"data":{"org":"kerouac","members":[]},"exit_code":0} | ||
| 141 | """, match: "argv=show")) | ||
| 142 | stub.enqueue(.init(status: 200, json: """ | ||
| 143 | {"protocol_version":1,"data":[],"exit_code":0} | ||
| 144 | """, match: "argv=team")) | ||
| 145 | let renamed = await model.rename(to: "kerouac") | ||
| 146 | |||
| 147 | #expect(renamed) | ||
| 148 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 149 | #expect(try argvOf(write) == ["org", "rename", "krz", "kerouac"]) | ||
| 150 | |||
| 151 | #expect(model.actionError == nil) | ||
| 152 | guard case .loaded = model.state else { | ||
| 153 | Issue.record("expected .loaded after a successful rename, got \(model.state)") | ||
| 154 | return | ||
| 155 | } | ||
| 156 | let reloads = try stub.seen.filter { $0.method == "GET" }.map { seen in | ||
| 157 | try #require(seen.url.query(percentEncoded: false)).components(separatedBy: "&") | ||
| 158 | } | ||
| 159 | for reload in reloads { | ||
| 160 | #expect(reload.contains("argv=kerouac"), | ||
| 161 | "expected the reload to target the new name, got \(reload)") | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | @Test func aBlankRenameSendsNothing() async throws { | ||
| 166 | let (client, stub) = try makeClient() | ||
| 167 | let model = OrgViewModel(client: client, orgName: "krz") | ||
| 168 | let renamed = await model.rename(to: " ") | ||
| 169 | |||
| 170 | #expect(renamed == false) | ||
| 171 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 172 | } | ||
| 173 | |||
| 174 | @Test func createSendsTheName() async throws { | ||
| 175 | let (client, stub) = try makeClient() | ||
| 176 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 177 | stub.enqueue(.init(status: 200, json: ok)) | ||
| 178 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 179 | let created = await model.createOrg("newco") | ||
| 180 | |||
| 181 | #expect(created) | ||
| 182 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 183 | #expect(try argvOf(write) == ["org", "create", "newco"]) | ||
| 184 | } | ||
| 185 | |||
| 186 | @Test func aBlankCreateSendsNothing() async throws { | ||
| 187 | let (client, stub) = try makeClient() | ||
| 188 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 189 | let created = await model.createOrg(" ") | ||
| 190 | |||
| 191 | #expect(created == false) | ||
| 192 | #expect(stub.seen.filter { $0.method == "POST" }.isEmpty) | ||
| 193 | } | ||
| 194 | } | ||